Преглед на файлове

Stacks v1 (#2972)

* 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

* package

* 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

* Update Dockerfile paths

* handle registry deploy

* temp always deploy porter app

* i think it works end to end now

* Duplicate Name

* Builder

* remove duplicate stack create

* Clear Docker

* load in helm release data

* write image uri

* Header

* list stub and make build fields not required

* Changes to ExpandedApp

* updating gha

* Revision Changes

* add question mark lol

* welcome to stacks

* adding support for custom domain

* sad

* update endpoint?

* fix that lol

* Add BuildPacks

* expandable section

* update

* update stack query

* expand apps from list

* list apps

* adding basic error handling

* list apps

* patch createapprequest type

* use new components for header

* Render Contents Fix

* Dockerfile detection fix

* redirect on app create

* fixed up headers

* set git repo id

* Buildpack

* build settings modal

* consolidate build modal components

* standard confirm overlay

* Changes to Selector

* Changes to Selector

* deletion without namespace cleanup

* Final BuildPacks

* clean up update app request

* chain ns deletion from fe for now

* Final BuildPacks

* read helm values back into services

* dashboard -> FC for stacks /apps route default

* fix nil image

* enforce uniqueness on stack creation

* Update Build Settings

* redirect after app deletion

* Porter.Yaml

* deploy services

* updating services from dashboard works now

* fetch porter yaml on component load

* formatting

* readonly services

* banner

* BuildSettingsWrapUp

* work to enable subdomains on the backend

* BuildSettingsWrapUp

* Error Handling

* Error Handling

* banner w/o casing on workflow yet

* fixing bug with branch

* cleanup

* EnvTab

* adding error case

* disabled on checkboxes

* disabled tooltip

* disable checkbox and tooltips

* GHA status check

* adding log and events tab

* fix ram -> memory

* gha status check

* resolved conflicts

* null check

* hide events and metrics

* fix no repo case

* Testing

* Merge

* fix launch flow styling

* fix radio bug

* cleaned up build settings

* update buttons on expanded app

* Github Modal

* update banner for github repo

* fix close tag

* gha modal default false

---------

Co-authored-by: Justin Rhee <jusrhee@sas.upenn.edu>
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>
Co-authored-by: jusrhee <justin@porter.run>
Co-authored-by: David Townley <davidtownley@Davids-MacBook-Air.local>
Feroze Mohideen преди 3 години
родител
ревизия
c71c1dc97f
променени са 100 файла, в които са добавени 7126 реда и са изтрити 1338 реда
  1. 5 4
      api/server/handlers/gitinstallation/get_contents.go
  2. 14 0
      api/server/handlers/release/get_all_pods.go
  3. 37 6
      api/server/handlers/release/get_controllers.go
  4. 24 15
      api/server/handlers/stacks/create.go
  5. 21 8
      api/server/handlers/stacks/create_porter_app.go
  6. 55 0
      api/server/handlers/stacks/delete_porter_app.go
  7. 1 1
      api/server/handlers/stacks/get_porter_app.go
  8. 44 0
      api/server/handlers/stacks/list_porter_app.go
  9. 136 13
      api/server/handlers/stacks/parse.go
  10. 20 8
      api/server/handlers/stacks/update.go
  11. 80 0
      api/server/handlers/stacks/update_porter_app.go
  12. 87 1
      api/server/router/stack.go
  13. 34 4
      api/types/porter_app.go
  14. 1 0
      api/types/request.go
  15. 0 13
      api/types/stacks.go
  16. BIN
      dashboard/src/assets/box.png
  17. BIN
      dashboard/src/assets/infra.png
  18. BIN
      dashboard/src/assets/refresh.png
  19. 9 2
      dashboard/src/components/CopyToClipboard.tsx
  20. 94 0
      dashboard/src/components/LogQueryModeSelectionToggle.tsx
  21. 75 0
      dashboard/src/components/LogSearchBar.tsx
  22. 1 1
      dashboard/src/components/ProvisionerFlow.tsx
  23. 1 1
      dashboard/src/components/ProvisionerSettings.tsx
  24. 4 2
      dashboard/src/components/RadioFilter.tsx
  25. 5 8
      dashboard/src/components/TitleSection.tsx
  26. 117 0
      dashboard/src/components/TitleSectionStacks.tsx
  27. 9 9
      dashboard/src/components/image-selector/ImageList.tsx
  28. 26 7
      dashboard/src/components/porter/Banner.tsx
  29. 34 11
      dashboard/src/components/porter/Checkbox.tsx
  30. 104 0
      dashboard/src/components/porter/ConfirmOverlay.tsx
  31. 39 5
      dashboard/src/components/porter/ExpandableSection.tsx
  32. 59 27
      dashboard/src/components/porter/Input.tsx
  33. 21 6
      dashboard/src/components/porter/Link.tsx
  34. 8 4
      dashboard/src/components/porter/Modal.tsx
  35. 21 22
      dashboard/src/components/porter/Select.tsx
  36. 2 1
      dashboard/src/components/porter/Toggle.tsx
  37. 7 4
      dashboard/src/components/porter/Tooltip.tsx
  38. 1 1
      dashboard/src/components/porter/VerticalSteps.tsx
  39. 32 12
      dashboard/src/components/repo-selector/ActionConfBranchSelector.tsx
  40. 0 1
      dashboard/src/components/repo-selector/BuildpackSelection.tsx
  41. 169 94
      dashboard/src/components/repo-selector/BuildpackStack.tsx
  42. 21 55
      dashboard/src/components/repo-selector/DetectContentsList.tsx
  43. 27 33
      dashboard/src/main/home/Home.tsx
  44. 6 3
      dashboard/src/main/home/add-on-dashboard/AddOnDashboard.tsx
  45. 183 72
      dashboard/src/main/home/app-dashboard/AppDashboard.tsx
  46. 43 0
      dashboard/src/main/home/app-dashboard/expanded-app/AppEvents.tsx
  47. 447 0
      dashboard/src/main/home/app-dashboard/expanded-app/BuildSettingsTabStack.tsx
  48. 9 0
      dashboard/src/main/home/app-dashboard/expanded-app/DisabledNamespaces.ts
  49. 56 0
      dashboard/src/main/home/app-dashboard/expanded-app/EnvVariablesTab.tsx
  50. 637 0
      dashboard/src/main/home/app-dashboard/expanded-app/EventList.tsx
  51. 224 0
      dashboard/src/main/home/app-dashboard/expanded-app/EventsTab.tsx
  52. 805 66
      dashboard/src/main/home/app-dashboard/expanded-app/ExpandedApp.tsx
  53. 122 0
      dashboard/src/main/home/app-dashboard/expanded-app/GHABanner.tsx
  54. 652 0
      dashboard/src/main/home/app-dashboard/expanded-app/LogSection.tsx
  55. 139 0
      dashboard/src/main/home/app-dashboard/expanded-app/SharedBuildSettings.tsx
  56. 429 0
      dashboard/src/main/home/app-dashboard/expanded-app/useAgentLogs.ts
  57. 32 27
      dashboard/src/main/home/app-dashboard/new-app-flow/AdvancedBuildSettings.tsx
  58. 122 64
      dashboard/src/main/home/app-dashboard/new-app-flow/GithubActionModal.tsx
  59. 204 0
      dashboard/src/main/home/app-dashboard/new-app-flow/GithubConnectModal.tsx
  60. 35 14
      dashboard/src/main/home/app-dashboard/new-app-flow/JobTabs.tsx
  61. 306 76
      dashboard/src/main/home/app-dashboard/new-app-flow/NewAppFlow.tsx
  62. 10 8
      dashboard/src/main/home/app-dashboard/new-app-flow/ServiceContainer.tsx
  63. 34 11
      dashboard/src/main/home/app-dashboard/new-app-flow/Services.tsx
  64. 102 89
      dashboard/src/main/home/app-dashboard/new-app-flow/SourceSettings.tsx
  65. 62 26
      dashboard/src/main/home/app-dashboard/new-app-flow/WebTabs.tsx
  66. 46 28
      dashboard/src/main/home/app-dashboard/new-app-flow/WorkerTabs.tsx
  67. 86 6
      dashboard/src/main/home/app-dashboard/new-app-flow/schema.tsx
  68. 355 51
      dashboard/src/main/home/app-dashboard/new-app-flow/serviceTypes.ts
  69. 43 0
      dashboard/src/main/home/app-dashboard/new-app-flow/utils.ts
  70. 43 41
      dashboard/src/main/home/cluster-dashboard/dashboard/ProvisionerStatus.tsx
  71. 16 20
      dashboard/src/main/home/cluster-dashboard/env-groups/EnvGroupArray.tsx
  72. 104 0
      dashboard/src/main/home/cluster-dashboard/expanded-chart/DeploymentTypeStacks.tsx
  73. 5 1
      dashboard/src/main/home/cluster-dashboard/expanded-chart/ExpandedChart.tsx
  74. 1 1
      dashboard/src/main/home/cluster-dashboard/expanded-chart/NotificationSettingsSection.tsx
  75. 2 2
      dashboard/src/main/home/cluster-dashboard/expanded-chart/RevisionSection.tsx
  76. 1 1
      dashboard/src/main/home/cluster-dashboard/expanded-chart/build-settings/BuildSettingsTab.tsx
  77. 1 1
      dashboard/src/main/home/cluster-dashboard/expanded-chart/logs-section/LogsSection.tsx
  78. 1 1
      dashboard/src/main/home/cluster-dashboard/preview-environments/components/PreviewEnvironmentsHeader.tsx
  79. 1 1
      dashboard/src/main/home/cluster-dashboard/preview-environments/deployments/DeploymentDetail.tsx
  80. 1 1
      dashboard/src/main/home/cluster-dashboard/preview-environments/deployments/DeploymentList.tsx
  81. 1 1
      dashboard/src/main/home/cluster-dashboard/preview-environments/environments/CreateBranchEnvironment.tsx
  82. 1 1
      dashboard/src/main/home/cluster-dashboard/preview-environments/environments/CreatePREnvironment.tsx
  83. 1 1
      dashboard/src/main/home/cluster-dashboard/preview-environments/environments/EnvironmentSettings.tsx
  84. 1 1
      dashboard/src/main/home/dashboard/ClusterSection.tsx
  85. 144 230
      dashboard/src/main/home/dashboard/Dashboard.tsx
  86. 1 1
      dashboard/src/main/home/integrations/IntegrationList.tsx
  87. 1 1
      dashboard/src/main/home/integrations/Integrations.tsx
  88. 1 1
      dashboard/src/main/home/integrations/SlackIntegrationList.tsx
  89. 1 1
      dashboard/src/main/home/integrations/create-integration/ECRForm.tsx
  90. 15 50
      dashboard/src/main/home/modals/EnvEditorModal.tsx
  91. 1 1
      dashboard/src/main/home/modals/UsageWarningModal.tsx
  92. 1 1
      dashboard/src/main/home/onboarding/Onboarding.tsx
  93. 3 0
      dashboard/src/main/home/sidebar/Clusters.tsx
  94. 18 0
      dashboard/src/main/home/sidebar/Sidebar.tsx
  95. 90 33
      dashboard/src/shared/api.tsx
  96. 1 0
      dashboard/src/shared/themes/midnight.ts
  97. 1 0
      dashboard/src/shared/themes/standard.ts
  98. 18 16
      internal/models/porter_app.go
  99. 13 7
      internal/repository/gorm/porter_app.go
  100. 3 2
      internal/repository/porter_app.go

+ 5 - 4
api/server/handlers/gitinstallation/get_contents.go

@@ -1,7 +1,6 @@
 package gitinstallation
 
 import (
-	"context"
 	"net/http"
 
 	"github.com/google/go-github/v41/github"
@@ -30,6 +29,7 @@ func NewGithubGetContentsHandler(
 }
 
 func (c *GithubGetContentsHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	ctx := r.Context()
 	request := &types.GetContentsRequest{}
 
 	ok := c.DecodeAndValidate(w, r, request)
@@ -58,15 +58,16 @@ func (c *GithubGetContentsHandler) ServeHTTP(w http.ResponseWriter, r *http.Requ
 
 	repoContentOptions := github.RepositoryContentGetOptions{}
 	repoContentOptions.Ref = branch
-	_, directoryContents, _, err := client.Repositories.GetContents(
-		context.Background(),
+	_, directoryContents, resp, err := client.Repositories.GetContents(
+		ctx,
 		owner,
 		name,
 		request.Dir,
 		&repoContentOptions,
 	)
 	if err != nil {
-		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(
+			err, resp.StatusCode))
 		return
 	}
 

+ 14 - 0
api/server/handlers/release/get_all_pods.go

@@ -39,6 +39,7 @@ func (c *GetAllPodsHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
 
 	agent, err := c.GetAgent(r, cluster, "")
 	if err != nil {
+		err = fmt.Errorf("error getting agent: %w", err)
 		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
 		return
 	}
@@ -52,6 +53,7 @@ func (c *GetAllPodsHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
 		controller.Namespace = helmRelease.Namespace
 		_, selector, err := getController(controller, agent)
 		if err != nil {
+			err = fmt.Errorf("error getting controller %s: %w", controller.Name, err)
 			c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
 			return
 		}
@@ -74,6 +76,7 @@ func (c *GetAllPodsHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
 
 			jobPods, err := getPodsForJobs(agent, helmRelease.Namespace, jobLabels)
 			if err != nil {
+				err = fmt.Errorf("error getting cronjob pods in namespace %s with labels %+v : %w", helmRelease.Namespace, jobLabels, err)
 				c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
 				return
 			}
@@ -93,6 +96,16 @@ func (c *GetAllPodsHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
 
 		podList, err := agent.GetPodsByLabel(strings.Join(selectors, ","), helmRelease.Namespace)
 		if err != nil {
+			err = fmt.Errorf("error getting pods in namespace %s with labels %+v : %w", helmRelease.Namespace, strings.Join(selectors, ","), err)
+			c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+			return
+		}
+
+		pods = append(pods, podList.Items...)
+
+		podList, err = agent.GetPodsByLabel(strings.Join(selectors, ","), "default")
+		if err != nil {
+			err = fmt.Errorf("error getting pods in namespace %s with labels %+v : %w", helmRelease.Namespace, strings.Join(selectors, ","), err)
 			c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
 			return
 		}
@@ -110,6 +123,7 @@ func (c *GetAllPodsHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
 
 	jobPods, err := getPodsForJobs(agent, helmRelease.Namespace, labels)
 	if err != nil {
+		err = fmt.Errorf("error getting cronjob pods in namespace %s with labels %+v : %w", helmRelease.Namespace, labels, err)
 		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
 		return
 	}

+ 37 - 6
api/server/handlers/release/get_controllers.go

@@ -77,35 +77,61 @@ func getController(controller grapher.Object, agent *kubernetes.Agent) (rc inter
 	case "deployment":
 		obj, err := agent.GetDeployment(controller)
 		if err != nil {
-			return nil, nil, err
+			controller.Namespace = "default"
+			obj, err = agent.GetDeployment(controller)
+			if err != nil {
+				err = fmt.Errorf("error getting deployment: %w", err)
+				return nil, nil, err
+			}
 		}
 
 		return obj, obj.Spec.Selector, nil
 	case "statefulset":
+
 		obj, err := agent.GetStatefulSet(controller)
 		if err != nil {
-			return nil, nil, err
+			controller.Namespace = "default"
+			obj, err = agent.GetStatefulSet(controller)
+			if err != nil {
+				err = fmt.Errorf("error getting stateful set: %w", err)
+				return nil, nil, err
+			}
 		}
 
 		return obj, obj.Spec.Selector, nil
 	case "daemonset":
 		obj, err := agent.GetDaemonSet(controller)
 		if err != nil {
-			return nil, nil, err
+			controller.Namespace = "default"
+			obj, err = agent.GetDaemonSet(controller)
+			if err != nil {
+				err = fmt.Errorf("error getting daemon set: %w", err)
+				return nil, nil, err
+			}
 		}
 
 		return obj, obj.Spec.Selector, nil
 	case "replicaset":
 		obj, err := agent.GetReplicaSet(controller)
 		if err != nil {
-			return nil, nil, err
+			controller.Namespace = "default"
+			obj, err = agent.GetReplicaSet(controller)
+			if err != nil {
+				err = fmt.Errorf("error getting replica set: %w", err)
+				return nil, nil, err
+			}
 		}
 
 		return obj, obj.Spec.Selector, nil
 	case "cronjob":
 		obj, err := agent.GetCronJob(controller)
 		if err != nil {
-			return nil, nil, err
+			controller.Namespace = "default"
+			obj, err = agent.GetCronJob(controller)
+			if err != nil {
+				err = fmt.Errorf("error getting cron job %w", err)
+				return nil, nil, err
+			}
 		}
 
 		res := &metav1.LabelSelector{
@@ -120,7 +146,12 @@ func getController(controller grapher.Object, agent *kubernetes.Agent) (rc inter
 	case "job":
 		obj, err := agent.GetJob(controller)
 		if err != nil {
-			return nil, nil, err
+			controller.Namespace = "default"
+			obj, err = agent.GetJob(controller)
+			if err != nil {
+				err = fmt.Errorf("error getting job: %w", err)
+				return nil, nil, err
+			}
 		}
 
 		return obj, obj.Spec.Selector, nil

+ 24 - 15
api/server/handlers/stacks/create.go

@@ -40,31 +40,38 @@ func (c *CreateStackHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
 		c.HandleAPIError(w, r, apierrors.NewErrInternal(fmt.Errorf("error decoding request")))
 		return
 	}
+
 	stackName := request.StackName
 	namespace := fmt.Sprintf("porter-stack-%s", stackName)
-	porterYamlBase64 := request.PorterYAMLBase64
-	porterYaml, err := base64.StdEncoding.DecodeString(porterYamlBase64)
+
+	helmAgent, err := c.GetHelmAgent(r, cluster, namespace)
 	if err != nil {
-		c.HandleAPIError(w, r, apierrors.NewErrInternal(fmt.Errorf("error decoding porter yaml: %w", err)))
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(fmt.Errorf("error getting helm agent: %w", err)))
 		return
 	}
 
-	imageInfo := request.ImageInfo
-	chart, values, err := parse(porterYaml, &imageInfo, c.Config(), cluster.ProjectID)
+	k8sAgent, err := c.GetAgent(r, cluster, namespace)
 	if err != nil {
-		c.HandleAPIError(w, r, apierrors.NewErrInternal(fmt.Errorf("error with test: %w", err)))
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(fmt.Errorf("error getting k8s agent: %w", err)))
 		return
 	}
 
-	helmAgent, err := c.GetHelmAgent(r, cluster, namespace)
+	porterYamlBase64 := request.PorterYAMLBase64
+	porterYaml, err := base64.StdEncoding.DecodeString(porterYamlBase64)
 	if err != nil {
-		c.HandleAPIError(w, r, apierrors.NewErrInternal(fmt.Errorf("error getting helm agent: %w", err)))
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(fmt.Errorf("error decoding porter yaml: %w", err)))
 		return
 	}
-
-	k8sAgent, err := c.GetAgent(r, cluster, namespace)
+	imageInfo := request.ImageInfo
+	chart, values, err := parse(porterYaml, imageInfo, c.Config(), cluster.ProjectID, SubdomainCreateOpts{
+		k8sAgent:       k8sAgent,
+		dnsRepo:        c.Repo().DNSRecord(),
+		powerDnsClient: c.Config().PowerDNSClient,
+		appRootDomain:  c.Config().ServerConf.AppRootDomain,
+		stackName:      stackName,
+	})
 	if err != nil {
-		c.HandleAPIError(w, r, apierrors.NewErrInternal(fmt.Errorf("error getting k8s agent: %w", err)))
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(fmt.Errorf("error parsing porter yaml into chart and values: %w", err)))
 		return
 	}
 
@@ -93,10 +100,12 @@ func (c *CreateStackHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
 
 	_, err = helmAgent.InstallChart(conf, c.Config().DOConf, c.Config().ServerConf.DisablePullSecretsInjection)
 	if err != nil {
-		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(
-			fmt.Errorf("error installing a new chart: %s", err.Error()),
-			http.StatusBadRequest,
-		))
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(fmt.Errorf("error deploying app: %s", err.Error())))
+
+		_, err = helmAgent.UninstallChart(stackName)
+		if err != nil {
+			c.HandleAPIError(w, r, apierrors.NewErrInternal(fmt.Errorf("error uninstalling chart: %w", err)))
+		}
 
 		return
 	}

+ 21 - 8
api/server/handlers/stacks/create_porter_app.go

@@ -1,11 +1,13 @@
 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/types"
 	"github.com/porter-dev/porter/internal/models"
@@ -34,29 +36,40 @@ func (c *CreatePorterAppHandler) ServeHTTP(w http.ResponseWriter, r *http.Reques
 	request := &types.CreatePorterAppRequest{}
 
 	ok := c.DecodeAndValidate(w, r, request)
-
 	if !ok {
 		return
 	}
 
+	existing, err := c.Repo().PorterApp().ReadPorterAppByName(cluster.ID, request.Name)
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	} else if existing.Name != "" {
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(
+			fmt.Errorf("porter app with name %s already exists in this environment", existing.Name), http.StatusForbidden))
+		return
+	}
+
 	app := &models.PorterApp{
 		Name:      request.Name,
 		ClusterID: cluster.ID,
 		ProjectID: project.ID,
 		RepoName:  request.RepoName,
+		GitRepoID: request.GitRepoID,
 		GitBranch: request.GitBranch,
 
-		BuildContext: request.BuildContext,
-		Builder:      request.Builder,
-		Buildpacks:   request.Buildpacks,
-		Dockerfile:   request.Dockerfile,
+		BuildContext:   request.BuildContext,
+		Builder:        request.Builder,
+		Buildpacks:     request.Buildpacks,
+		Dockerfile:     request.Dockerfile,
+		ImageRepoURI:   request.ImageRepoURI,
+		PullRequestURL: request.PullRequestURL,
 	}
 
-	_, err := c.Repo().PorterApp().CreatePorterApp(app)
-
+	porterApp, err := c.Repo().PorterApp().UpdatePorterApp(app)
 	if err != nil {
 		return
 	}
 
-	w.WriteHeader(http.StatusCreated)
+	c.WriteResult(w, r, porterApp.ToPorterAppType())
 }

+ 55 - 0
api/server/handlers/stacks/delete_porter_app.go

@@ -0,0 +1,55 @@
+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/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 DeletePorterAppByNameHandler struct {
+	handlers.PorterHandlerReadWriter
+	authz.KubernetesAgentGetter
+}
+
+func NewDeletePorterAppByNameHandler(
+	config *config.Config,
+	decoderValidator shared.RequestDecoderValidator,
+	writer shared.ResultWriter,
+) *DeletePorterAppByNameHandler {
+	return &DeletePorterAppByNameHandler{
+		PorterHandlerReadWriter: handlers.NewDefaultPorterHandler(config, decoderValidator, writer),
+		KubernetesAgentGetter:   authz.NewOutOfClusterAgentGetter(config),
+	}
+}
+
+func (c *DeletePorterAppByNameHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	ctx := r.Context()
+	cluster, _ := ctx.Value(types.ClusterScope).(*models.Cluster)
+
+	name, reqErr := requestutils.GetURLParamString(r, types.URLParamReleaseName)
+	if reqErr != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(reqErr, http.StatusBadRequest))
+		return
+	}
+
+	porterApp, appErr := c.Repo().PorterApp().ReadPorterAppByName(cluster.ID, name)
+	if appErr != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(appErr))
+		return
+	}
+
+	delApp, delErr := c.Repo().PorterApp().DeletePorterApp(porterApp)
+	if delErr != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(delErr))
+		return
+	}
+
+	c.WriteResult(w, r, delApp)
+}

+ 1 - 1
api/server/handlers/stacks/get_porter_app.go

@@ -32,7 +32,7 @@ func (c *GetPorterAppHandler) ServeHTTP(w http.ResponseWriter, r *http.Request)
 	cluster, _ := ctx.Value(types.ClusterScope).(*models.Cluster)
 	name, _ := requestutils.GetURLParamString(r, types.URLParamReleaseName)
 
-	app, err := c.Repo().PorterApp().ReadPorterApp(cluster.ID, name)
+	app, err := c.Repo().PorterApp().ReadPorterAppByName(cluster.ID, name)
 	if err != nil {
 		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
 		return

+ 44 - 0
api/server/handlers/stacks/list_porter_app.go

@@ -0,0 +1,44 @@
+package stacks
+
+import (
+	"net/http"
+
+	"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/types"
+	"github.com/porter-dev/porter/internal/models"
+)
+
+type PorterAppListHandler struct {
+	handlers.PorterHandlerWriter
+}
+
+func NewPorterAppListHandler(
+	config *config.Config,
+	writer shared.ResultWriter,
+) *PorterAppListHandler {
+	return &PorterAppListHandler{
+		PorterHandlerWriter: handlers.NewDefaultPorterHandler(config, nil, writer),
+	}
+}
+
+func (p *PorterAppListHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	ctx := r.Context()
+	cluster, _ := ctx.Value(types.ClusterScope).(*models.Cluster)
+
+	porterApps, err := p.Repo().PorterApp().ListPorterAppByClusterID(cluster.ID)
+	if err != nil {
+		p.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	res := make(types.ListPorterAppResponse, 0)
+
+	for _, porterApp := range porterApps {
+		res = append(res, porterApp.ToPorterAppType())
+	}
+
+	p.WriteResult(w, r, res)
+}

+ 136 - 13
api/server/handlers/stacks/parse.go

@@ -7,8 +7,13 @@ import (
 	"github.com/porter-dev/porter/api/server/shared/config"
 	"github.com/porter-dev/porter/api/types"
 	"github.com/porter-dev/porter/internal/helm/loader"
+	"github.com/porter-dev/porter/internal/integrations/powerdns"
+	"github.com/porter-dev/porter/internal/kubernetes"
+	"github.com/porter-dev/porter/internal/kubernetes/domain"
+	"github.com/porter-dev/porter/internal/repository"
 	"github.com/porter-dev/porter/internal/templater/utils"
 	"github.com/stefanmcshane/helm/pkg/chart"
+
 	"gopkg.in/yaml.v2"
 )
 
@@ -35,7 +40,15 @@ type App struct {
 	Type   *string                `yaml:"type" validate:"required, oneof=web worker job"`
 }
 
-func parse(porterYaml []byte, imageInfo *types.ImageInfo, config *config.Config, projectID uint) (*chart.Chart, map[string]interface{}, error) {
+type SubdomainCreateOpts struct {
+	k8sAgent       *kubernetes.Agent
+	dnsRepo        repository.DNSRecordRepository
+	powerDnsClient *powerdns.Client
+	appRootDomain  string
+	stackName      string
+}
+
+func parse(porterYaml []byte, imageInfo types.ImageInfo, config *config.Config, projectID uint, opts SubdomainCreateOpts) (*chart.Chart, map[string]interface{}, error) {
 	parsed := &PorterStackYAML{}
 
 	err := yaml.Unmarshal(porterYaml, parsed)
@@ -43,35 +56,41 @@ func parse(porterYaml []byte, imageInfo *types.ImageInfo, config *config.Config,
 		return nil, nil, fmt.Errorf("%s: %w", "error parsing porter.yaml", err)
 	}
 
-	values, err := buildStackValues(parsed, imageInfo)
+	values, err := buildStackValues(parsed, imageInfo, opts)
 	if err != nil {
 		return nil, nil, fmt.Errorf("%s: %w", "error building values from porter.yaml", err)
 	}
-	convertedValues := convertMap(values)
+	convertedValues := convertMap(values).(map[string]interface{})
 
 	chart, err := buildStackChart(parsed, config, projectID)
 	if err != nil {
 		return nil, nil, fmt.Errorf("%s: %w", "error building chart from porter.yaml", err)
 	}
 
-	return chart, convertedValues.(map[string]interface{}), nil
+	return chart, convertedValues, nil
 }
 
-func buildStackValues(parsed *PorterStackYAML, imageInfo *types.ImageInfo) (map[string]interface{}, error) {
+func buildStackValues(parsed *PorterStackYAML, imageInfo types.ImageInfo, opts SubdomainCreateOpts) (map[string]interface{}, error) {
 	values := make(map[string]interface{})
 
 	for name, app := range parsed.Apps {
 		appType := getType(name, app)
 		defaultValues := getDefaultValues(app, parsed.Env, appType)
-		helm_values := utils.CoalesceValues(defaultValues, app.Config)
+		convertedConfig := convertMap(app.Config).(map[string]interface{})
+		helm_values := utils.DeepCoalesceValues(defaultValues, convertedConfig)
+		err := createSubdomainIfRequired(helm_values, opts) // modifies helm_values to add subdomains if necessary
+		if err != nil {
+			return nil, err
+		}
 		values[name] = helm_values
-		if imageInfo != nil {
-			values["global"] = map[string]interface{}{
-				"image": map[string]interface{}{
-					"repository": imageInfo.Repository,
-					"tag":        imageInfo.Tag,
-				},
-			}
+	}
+
+	if imageInfo.Repository != "" && imageInfo.Tag != "" {
+		values["global"] = map[string]interface{}{
+			"image": map[string]interface{}{
+				"repository": imageInfo.Repository,
+				"tag":        imageInfo.Tag,
+			},
 		}
 	}
 
@@ -231,3 +250,107 @@ func CopyEnv(env map[string]string) map[string]string {
 
 	return envCopy
 }
+
+func createSubdomainIfRequired(
+	mergedValues map[string]interface{},
+	opts SubdomainCreateOpts,
+) error {
+	// look for ingress.enabled and no custom domains set
+	ingressMap, err := getNestedMap(mergedValues, "ingress")
+	if err == nil {
+		enabledVal, enabledExists := ingressMap["enabled"]
+		customDomVal, customDomExists := ingressMap["custom_domain"]
+
+		if enabledExists && customDomExists {
+			enabled, eOK := enabledVal.(bool)
+			customDomain, cOK := customDomVal.(bool)
+
+			if eOK && cOK && enabled && !customDomain {
+				// in the case of ingress enabled but no custom domain, create subdomain
+				dnsRecord, err := createDNSRecord(opts)
+				if err != nil {
+					return fmt.Errorf("error creating subdomain: %s", err.Error())
+				}
+
+				subdomain := dnsRecord.ExternalURL
+
+				if ingressVal, ok := mergedValues["ingress"]; !ok {
+					mergedValues["ingress"] = map[string]interface{}{
+						"porter_hosts": []string{
+							subdomain,
+						},
+					}
+				} else {
+					ingressValMap := ingressVal.(map[string]interface{})
+
+					ingressValMap["porter_hosts"] = []string{
+						subdomain,
+					}
+				}
+			}
+		}
+	}
+
+	return nil
+}
+
+func createDNSRecord(opts SubdomainCreateOpts) (*types.DNSRecord, error) {
+	if opts.powerDnsClient == nil {
+		return nil, fmt.Errorf("cannot create subdomain because powerdns client is nil")
+	}
+
+	endpoint, found, err := domain.GetNGINXIngressServiceIP(opts.k8sAgent.Clientset)
+	if err != nil {
+		return nil, err
+	}
+	if !found {
+		return nil, fmt.Errorf("target cluster does not have nginx ingress")
+	}
+
+	createDomain := domain.CreateDNSRecordConfig{
+		ReleaseName: opts.stackName,
+		RootDomain:  opts.appRootDomain,
+		Endpoint:    endpoint,
+	}
+
+	record := createDomain.NewDNSRecordForEndpoint()
+
+	record, err = opts.dnsRepo.CreateDNSRecord(record)
+
+	if err != nil {
+		return nil, err
+	}
+
+	_record := domain.DNSRecord(*record)
+
+	err = _record.CreateDomain(opts.powerDnsClient)
+
+	if err != nil {
+		return nil, err
+	}
+
+	return record.ToDNSRecordType(), nil
+}
+
+func getNestedMap(obj map[string]interface{}, fields ...string) (map[string]interface{}, error) {
+	var res map[string]interface{}
+	curr := obj
+
+	for _, field := range fields {
+		objField, ok := curr[field]
+
+		if !ok {
+			return nil, fmt.Errorf("%s not found", field)
+		}
+
+		res, ok = objField.(map[string]interface{})
+
+		if !ok {
+			return nil, fmt.Errorf("%s is not a nested object", field)
+		}
+
+		curr = res
+	}
+
+	return res, nil
+}

+ 20 - 8
api/server/handlers/stacks/update.go

@@ -43,23 +43,35 @@ func (c *UpdateStackHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
 
 	stackName := request.StackName
 	namespace := fmt.Sprintf("porter-stack-%s", stackName)
-	porterYamlBase64 := request.PorterYAMLBase64
-	porterYaml, err := base64.StdEncoding.DecodeString(porterYamlBase64)
+
+	helmAgent, err := c.GetHelmAgent(r, cluster, namespace)
 	if err != nil {
-		c.HandleAPIError(w, r, apierrors.NewErrInternal(fmt.Errorf("error decoding porter yaml: %w", err)))
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(fmt.Errorf("error getting helm agent: %w", err)))
 		return
 	}
 
-	imageInfo := request.ImageInfo
-	chart, values, err := parse(porterYaml, &imageInfo, c.Config(), cluster.ProjectID)
+	k8sAgent, err := c.GetAgent(r, cluster, namespace)
 	if err != nil {
-		c.HandleAPIError(w, r, apierrors.NewErrInternal(fmt.Errorf("error with test: %w", err)))
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(fmt.Errorf("error getting k8s agent: %w", err)))
 		return
 	}
 
-	helmAgent, err := c.GetHelmAgent(r, cluster, namespace)
+	porterYamlBase64 := request.PorterYAMLBase64
+	porterYaml, err := base64.StdEncoding.DecodeString(porterYamlBase64)
 	if err != nil {
-		c.HandleAPIError(w, r, apierrors.NewErrInternal(fmt.Errorf("error getting helm agent: %w", err)))
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(fmt.Errorf("error decoding porter yaml: %w", err)))
+		return
+	}
+	imageInfo := request.ImageInfo
+	chart, values, err := parse(porterYaml, imageInfo, c.Config(), cluster.ProjectID, SubdomainCreateOpts{
+		k8sAgent:       k8sAgent,
+		dnsRepo:        c.Repo().DNSRecord(),
+		powerDnsClient: c.Config().PowerDNSClient,
+		appRootDomain:  c.Config().ServerConf.AppRootDomain,
+		stackName:      stackName,
+	})
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(fmt.Errorf("error parsing porter yaml into chart and values: %w", err)))
 		return
 	}
 

+ 80 - 0
api/server/handlers/stacks/update_porter_app.go

@@ -0,0 +1,80 @@
+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/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 UpdatePorterAppHandler struct {
+	handlers.PorterHandlerReadWriter
+	authz.KubernetesAgentGetter
+}
+
+func NewUpdatePorterAppHandler(
+	config *config.Config,
+	decoderValidator shared.RequestDecoderValidator,
+	writer shared.ResultWriter,
+) *UpdatePorterAppHandler {
+	return &UpdatePorterAppHandler{
+		PorterHandlerReadWriter: handlers.NewDefaultPorterHandler(config, decoderValidator, writer),
+	}
+}
+
+func (c *UpdatePorterAppHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	ctx := r.Context()
+	cluster, _ := ctx.Value(types.ClusterScope).(*models.Cluster)
+
+	name, _ := requestutils.GetURLParamString(r, types.URLParamReleaseName)
+
+	porterApp, err := c.Repo().PorterApp().ReadPorterAppByName(cluster.ID, name)
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	request := &types.UpdatePorterAppRequest{}
+	ok := c.DecodeAndValidate(w, r, request)
+	if !ok {
+		return
+	}
+
+	if request.RepoName != "" {
+		porterApp.RepoName = request.RepoName
+	}
+	if request.GitBranch != "" {
+		porterApp.GitBranch = request.GitBranch
+	}
+	if request.BuildContext != "" {
+		porterApp.BuildContext = request.BuildContext
+	}
+	if request.Builder != "" {
+		porterApp.Builder = request.Builder
+	}
+	if request.Buildpacks != "" {
+		porterApp.Buildpacks = request.Buildpacks
+	}
+	if request.Dockerfile != "" {
+		porterApp.Dockerfile = request.Dockerfile
+	}
+	if request.ImageRepoURI != "" {
+		porterApp.ImageRepoURI = request.ImageRepoURI
+	}
+	if request.PullRequestURL != "" {
+		porterApp.PullRequestURL = request.PullRequestURL
+	}
+
+	updatedPorterApp, err := c.Repo().PorterApp().UpdatePorterApp(porterApp)
+	if err != nil {
+		return
+	}
+
+	c.WriteResult(w, r, updatedPorterApp.ToPorterAppType())
+}

+ 87 - 1
api/server/router/stack.go

@@ -83,7 +83,64 @@ func getStackRoutes(
 		Router:   r,
 	})
 
-	// POST /api/projects/{project_id}/clusters/{cluster_id}/stacks/update_config -> stacks.NewCreateStackHandler
+	// GET /api/projects/{project_id}/clusters/{cluster_id}/stacks/{name} -> stacks.NewPorterAppListHandler
+	listPorterAppEndpoint := factory.NewAPIEndpoint(
+		&types.APIRequestMetadata{
+			Verb:   types.APIVerbList,
+			Method: types.HTTPVerbGet,
+			Path: &types.Path{
+				Parent:       basePath,
+				RelativePath: relPath,
+			},
+			Scopes: []types.PermissionScope{
+				types.UserScope,
+				types.ProjectScope,
+				types.ClusterScope,
+			},
+		},
+	)
+
+	listPorterAppHandler := stacks.NewPorterAppListHandler(
+		config,
+		factory.GetResultWriter(),
+	)
+
+	routes = append(routes, &router.Route{
+		Endpoint: listPorterAppEndpoint,
+		Handler:  listPorterAppHandler,
+		Router:   r,
+	})
+
+	// DELETE /api/projects/{project_id}/clusters/{cluster_id}/stacks -> release.NewDeletePorterAppByNameHandler
+	deletePorterAppByNameEndpoint := factory.NewAPIEndpoint(
+		&types.APIRequestMetadata{
+			Verb:   types.APIVerbDelete,
+			Method: types.HTTPVerbDelete,
+			Path: &types.Path{
+				Parent:       basePath,
+				RelativePath: relPath + "/{name}",
+			},
+			Scopes: []types.PermissionScope{
+				types.UserScope,
+				types.ProjectScope,
+				types.ClusterScope,
+			},
+		},
+	)
+
+	deletePorterAppByNameHandler := stacks.NewDeletePorterAppByNameHandler(
+		config,
+		factory.GetDecoderValidator(),
+		factory.GetResultWriter(),
+	)
+
+	routes = append(routes, &router.Route{
+		Endpoint: deletePorterAppByNameEndpoint,
+		Handler:  deletePorterAppByNameHandler,
+		Router:   r,
+	})
+
+	// POST /api/projects/{project_id}/clusters/{cluster_id}/stacks/update_config -> stacks.NewCreatePorterAppHandler
 	createPorterAppEndpoint := factory.NewAPIEndpoint(
 		&types.APIRequestMetadata{
 			Verb:   types.APIVerbCreate,
@@ -112,6 +169,35 @@ func getStackRoutes(
 		Router:   r,
 	})
 
+	// POST /api/projects/{project_id}/clusters/{cluster_id}/stacks/{name} -> stacks.NewCreatePorterAppHandler
+	updatePorterAppEndpoint := factory.NewAPIEndpoint(
+		&types.APIRequestMetadata{
+			Verb:   types.APIVerbUpdate,
+			Method: types.HTTPVerbPost,
+			Path: &types.Path{
+				Parent:       basePath,
+				RelativePath: relPath + "/{name}",
+			},
+			Scopes: []types.PermissionScope{
+				types.UserScope,
+				types.ProjectScope,
+				types.ClusterScope,
+			},
+		},
+	)
+
+	updatePorterAppHandler := stacks.NewUpdatePorterAppHandler(
+		config,
+		factory.GetDecoderValidator(),
+		factory.GetResultWriter(),
+	)
+
+	routes = append(routes, &router.Route{
+		Endpoint: updatePorterAppEndpoint,
+		Handler:  updatePorterAppHandler,
+		Router:   r,
+	})
+
 	// POST /api/projects/{project_id}/clusters/{cluster_id}/stacks -> stacks.NewCreateStackHandler
 	createEndpoint := factory.NewAPIEndpoint(
 		&types.APIRequestMetadata{

+ 34 - 4
api/types/porter_app.go

@@ -15,8 +15,38 @@ type PorterApp struct {
 	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"`
+	BuildContext   string `json:"build_context,omitempty"`
+	Builder        string `json:"builder,omitempty"`
+	Buildpacks     string `json:"build_packs,omitempty"`
+	Dockerfile     string `json:"dockerfile,omitempty"`
+	PullRequestURL string `json:"pull_request_url,omitempty"`
 }
+
+// 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"`
+	GitBranch      string `json:"git_branch"`
+	GitRepoID      uint   `json:"git_repo_id"`
+	BuildContext   string `json:"build_context"`
+	Builder        string `json:"builder"`
+	Buildpacks     string `json:"buildpacks"`
+	Dockerfile     string `json:"dockerfile"`
+	ImageRepoURI   string `json:"image_repo_uri"`
+	PullRequestURL string `json:"pull_request_url"`
+}
+
+type UpdatePorterAppRequest struct {
+	RepoName       string `json:"repo_name"`
+	GitBranch      string `json:"git_branch"`
+	BuildContext   string `json:"build_context"`
+	Builder        string `json:"builder"`
+	Buildpacks     string `json:"buildpacks"`
+	Dockerfile     string `json:"dockerfile"`
+	ImageRepoURI   string `json:"image_repo_uri"`
+	PullRequestURL string `json:"pull_request_url"`
+}
+
+type ListPorterAppResponse []*PorterApp

+ 1 - 0
api/types/request.go

@@ -43,6 +43,7 @@ const (
 	URLParamInviteID              URLParam = "invite_id"
 	URLParamNamespace             URLParam = "namespace"
 	URLParamReleaseName           URLParam = "name"
+	URLParamPorterAppID           URLParam = "porter_app_id"
 	URLParamStackID               URLParam = "stack_id"
 	URLParamReleaseVersion        URLParam = "version"
 	URLParamWildcard              URLParam = "*"

+ 0 - 13
api/types/stacks.go

@@ -22,19 +22,6 @@ 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"`

BIN
dashboard/src/assets/box.png


BIN
dashboard/src/assets/infra.png


BIN
dashboard/src/assets/refresh.png


+ 9 - 2
dashboard/src/components/CopyToClipboard.tsx

@@ -10,7 +10,7 @@ type PropsType = {
   onError?: (e: ClipboardJS.Event) => void;
   wrapperProps?: any;
   as?: any;
-  children: JSX.Element[];
+  children: JSX.Element[] | JSX.Element | string;
 };
 
 type StateType = {
@@ -108,4 +108,11 @@ export default class CopyToClipboard extends Component<PropsType, StateType> {
   }
 }
 
-const DynamicSpanComponent = styled.span``;
+const DynamicSpanComponent = styled.span`
+  :hover {
+    cursor: pointer;
+    color: #ffffff;
+  }
+  color: #aaaabb;
+  font-size: 18px;
+`;

+ 94 - 0
dashboard/src/components/LogQueryModeSelectionToggle.tsx

@@ -0,0 +1,94 @@
+import DateTimePicker from "components/date-time-picker/DateTimePicker";
+import dayjs from "dayjs";
+import time from "assets/time.svg";
+import React from "react";
+import styled from "styled-components";
+
+interface LogQueryModeSelectionToggleProps {
+  selectedDate?: Date;
+  setSelectedDate: React.Dispatch<React.SetStateAction<Date>>;
+}
+
+const LogQueryModeSelectionToggle = (
+  props: LogQueryModeSelectionToggleProps
+) => {
+  return (
+    <div
+      style={{
+        marginRight: "10px",
+        display: "flex",
+        gap: "10px",
+      }}
+    >
+      <ToggleButton>
+        <ToggleOption
+          onClick={() => props.setSelectedDate(undefined)}
+          selected={!props.selectedDate}
+        >
+          <Dot selected={!props.selectedDate} />
+          Live
+        </ToggleOption>
+        <ToggleOption
+          nudgeLeft
+          onClick={() => props.setSelectedDate(dayjs().toDate())}
+          selected={!!props.selectedDate}
+        >
+          <TimeIcon src={time} selected={!!props.selectedDate} />
+          {props.selectedDate && (
+            <DateTimePicker
+              startDate={props.selectedDate}
+              setStartDate={props.setSelectedDate}
+            />
+          )}
+        </ToggleOption>
+      </ToggleButton>
+    </div>
+  );
+};
+
+export default LogQueryModeSelectionToggle;
+
+const ToggleOption = styled.div<{ selected: boolean; nudgeLeft?: boolean }>`
+  padding: 0 10px;
+  color: ${(props) => (props.selected ? "" : "#494b4f")};
+  border: 1px solid #494b4f;
+  height: 100%;
+  display: flex;
+  margin-left: ${(props) => (props.nudgeLeft ? "-1px" : "")};
+  align-items: center;
+  border-radius: ${(props) =>
+    props.nudgeLeft ? "0 5px 5px 0" : "5px 0 0 5px"};
+  :hover {
+    border: 1px solid #7a7b80;
+    z-index: 2;
+  }
+`;
+
+const ToggleButton = styled.div`
+  background: #26292e;
+  border-radius: 5px;
+  font-size: 13px;
+  height: 30px;
+  display: flex;
+  align-items: center;
+  cursor: pointer;
+`;
+
+const TimeIcon = styled.img<{ selected?: boolean }>`
+  width: 16px;
+  height: 16px;
+  z-index: 2;
+  opacity: ${(props) => (props.selected ? "" : "50%")};
+`;
+
+const Dot = styled.div<{ selected?: boolean }>`
+  display: inline-black;
+  width: 8px;
+  height: 8px;
+  margin-right: 9px;
+  border-radius: 20px;
+  background: ${(props) => (props.selected ? "#ed5f85" : "#ffffff22")};
+  border: 0px;
+  outline: none;
+  box-shadow: ${(props) => (props.selected ? "0px 0px 5px 1px #ed5f85" : "")};
+`;

+ 75 - 0
dashboard/src/components/LogSearchBar.tsx

@@ -0,0 +1,75 @@
+import React, { useState } from "react";
+import Button from "./Button";
+import styled from "styled-components";
+
+interface Props {
+  setEnteredSearchText: (x: string) => void;
+}
+
+const escapeRegExp = (str: string) => {
+  return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
+};
+
+const LogSearchBar: React.FC<Props> = ({ setEnteredSearchText }) => {
+  const [searchText, setSearchText] = useState("");
+
+  return (
+    <SearchRowWrapper>
+      <SearchBarWrapper>
+        <i className="material-icons">search</i>
+        <SearchInput
+          value={searchText}
+          onChange={(e: any) => {
+            setSearchText(e.target.value);
+          }}
+          onKeyPress={(event) => {
+            if (event.key === "Enter") {
+              setEnteredSearchText(escapeRegExp(searchText));
+            }
+          }}
+          placeholder="Search logs..."
+        />
+      </SearchBarWrapper>
+    </SearchRowWrapper>
+  );
+};
+
+export default LogSearchBar;
+
+const SearchBarWrapper = styled.div`
+  display: flex;
+  flex: 1;
+
+  > i {
+    color: #aaaabb;
+    padding-top: 1px;
+    margin-left: 8px;
+    font-size: 16px;
+    margin-right: 8px;
+  }
+`;
+
+const SearchInput = styled.input`
+  outline: none;
+  border: none;
+  font-size: 13px;
+  background: none;
+  width: 100%;
+  color: white;
+  height: 100%;
+`;
+
+const SearchRow = styled.div`
+  display: flex;
+  align-items: center;
+  height: 30px;
+  margin-right: 10px;
+  background: #26292e;
+  border-radius: 5px;
+  border: 1px solid #aaaabb33;
+`;
+
+const SearchRowWrapper = styled(SearchRow)`
+  border-radius: 5px;
+  width: 250px;
+`;

+ 1 - 1
dashboard/src/components/ProvisionerFlow.tsx

@@ -254,7 +254,7 @@ const Block = styled.div<{ disabled?: boolean }>`
   color: #ffffff;
   position: relative;
   border-radius: 5px;
-  background: linear-gradient(160deg, #26292e, #26292e);
+  background: ${({ theme }) => theme.clickable.bg};
   border: 1px solid #494b4f;
   :hover {
     border: ${(props) => (props.disabled ? "" : "1px solid #7a7b80")};

+ 1 - 1
dashboard/src/components/ProvisionerSettings.tsx

@@ -404,7 +404,7 @@ const StyledForm = styled.div`
   position: relative;
   padding: 30px 30px 25px;
   border-radius: 5px;
-  background: #26292e;
+  background: ${({ theme }) => theme.fg};
   border: 1px solid #494b4f;
   font-size: 13px;
   margin-bottom: 30px;

+ 4 - 2
dashboard/src/components/RadioFilter.tsx

@@ -85,7 +85,9 @@ const RadioFilter: React.FC<Props> = (props) => {
   return (
     <Relative>
       <StyledRadioFilter
-        onClick={() => setExpanded(!expanded)}
+        onClick={() => {
+          setExpanded(!expanded);
+        }}
         ref={parentRef}
         noMargin={props.noMargin}
       >
@@ -207,7 +209,7 @@ const StyledRadioFilter = styled.div<{ noMargin?: boolean }>`
   font-size: 13px;
   position: relative;
   padding: 10px;
-  background: ${props => props.theme.fg};
+  background: ${(props) => props.theme.fg};
   border-radius: 5px;
   display: flex;
   align-items: center;

+ 5 - 8
dashboard/src/components/TitleSection.tsx

@@ -41,10 +41,7 @@ const TitleSection: React.FC<Props> = ({
           <Icon width={iconWidth} src={icon} />
         ))}
 
-      <StyledTitle
-        capitalize={capitalize}
-        onClick={onClick}
-      >
+      <StyledTitle capitalize={capitalize} onClick={onClick}>
         {children}
       </StyledTitle>
     </StyledTitleSection>
@@ -85,19 +82,19 @@ const MaterialIcon = styled.span<{ width: string }>`
   margin-right: 16px;
 `;
 
-const StyledTitle = styled.div<{ 
+const StyledTitle = styled.div<{
   capitalize: boolean;
   onClick?: any;
 }>`
   font-size: 21px;
   user-select: text;
-  color: ${props => props.theme.text.primary};
+  color: ${(props) => props.theme.text.primary};
   text-transform: ${(props) => (props.capitalize ? "capitalize" : "")};
   display: flex;
   align-items: center;
-  cursor: ${props => props.onClick ? "pointer" : ""};
+  cursor: ${(props) => (props.onClick ? "pointer" : "")};
   :hover {
-    text-decoration: ${props => props.onClick ? "underline" : ""};
+    text-decoration: ${(props) => (props.onClick ? "underline" : "")};
   }
 
   > i {

+ 117 - 0
dashboard/src/components/TitleSectionStacks.tsx

@@ -0,0 +1,117 @@
+import React from "react";
+import styled from "styled-components";
+
+interface Props {
+  children: React.ReactNode;
+  icon?: any;
+  iconWidth?: string;
+  capitalize?: boolean;
+  className?: string;
+  materialIconClass?: string;
+  handleNavBack?: () => void;
+  onClick?: any;
+}
+
+const TitleSectionStacks: React.FC<Props> = ({
+  children,
+  icon,
+  iconWidth,
+  capitalize,
+  handleNavBack,
+  className,
+  materialIconClass,
+  onClick,
+}) => {
+  return (
+    <StyledTitleSection className={className}>
+      {handleNavBack && (
+        <BackButton>
+          <i className="material-icons" onClick={handleNavBack}>
+            keyboard_backspace
+          </i>
+        </BackButton>
+      )}
+
+      {icon}
+
+      <StyledTitle capitalize={capitalize} onClick={onClick}>
+        {children}
+      </StyledTitle>
+    </StyledTitleSection>
+  );
+};
+
+export default TitleSectionStacks;
+
+const BackButton = styled.div`
+  > i {
+    cursor: pointer;
+    font-size: 24px;
+    color: #aaaabb;
+    margin-right: 10px;
+    padding: 3px;
+    margin-left: 0px;
+    border-radius: 100px;
+    :hover {
+      background: #ffffff11;
+    }
+  }
+`;
+
+const StyledTitleSection = styled.div`
+  display: flex;
+  align-items: center;
+`;
+
+const Icon = styled.span<{ disableMarginRight: boolean }>`
+  font-size: 24px;
+  margin-right: 10px;
+  ${(props) => {
+    if (!props.disableMarginRight) {
+      return "margin-right: 20px";
+    }
+  }}
+`;
+
+const StyledTitle = styled.div<{
+  capitalize: boolean;
+  onClick?: any;
+}>`
+  font-size: 21px;
+  user-select: text;
+  color: ${(props) => props.theme.text.primary};
+  text-transform: ${(props) => (props.capitalize ? "capitalize" : "")};
+  display: flex;
+  align-items: center;
+  cursor: ${(props) => (props.onClick ? "pointer" : "")};
+  :hover {
+    text-decoration: ${(props) => (props.onClick ? "underline" : "")};
+  }
+
+  > i {
+    margin-left: 10px;
+    cursor: pointer;
+    font-size: 18px;
+    color: #858faaaa;
+    padding: 5px;
+    border-radius: 100px;
+    :hover {
+      background: #ffffff11;
+    }
+    margin-bottom: -3px;
+  }
+
+  > a {
+    > i {
+      display: flex;
+      align-items: center;
+      margin-bottom: -2px;
+      font-size: 18px;
+      margin-left: 15px;
+      color: #858faaaa;
+      :hover {
+        color: #aaaabb;
+      }
+    }
+  }
+`;

+ 9 - 9
dashboard/src/components/image-selector/ImageList.tsx

@@ -56,7 +56,7 @@ export default class ImageList extends Component<PropsType, StateType> {
 
     if (!this.props.registry) {
       api
-        .getProjectRegistries("<token>", {}, { id: currentProject.id })
+        .getProjectRegistries("<token>", {}, { id: currentProject?.id })
         .then((res) => {
           let registries = res.data;
           if (registries.length === 0) {
@@ -72,8 +72,8 @@ export default class ImageList extends Component<PropsType, StateType> {
                     "<token>",
                     {},
                     {
-                      project_id: currentProject.id,
-                      registry_id: registry.id,
+                      project_id: currentProject?.id,
+                      registry_id: registry?.id,
                     }
                   )
                   .then((res) => {
@@ -87,14 +87,14 @@ export default class ImageList extends Component<PropsType, StateType> {
                           kind: registry.service,
                           source: img.uri,
                           name: img.name,
-                          registryId: registry.id,
+                          registryId: registry?.id,
                         });
                       }
                       return {
                         kind: registry.service,
                         source: img.uri,
                         name: img.name,
-                        registryId: registry.id,
+                        registryId: registry?.id,
                       };
                     });
                     images.push(...newImg);
@@ -137,8 +137,8 @@ export default class ImageList extends Component<PropsType, StateType> {
           "<token>",
           {},
           {
-            project_id: currentProject.id,
-            registry_id: this.props.registry.id,
+            project_id: currentProject?.id,
+            registry_id: this.props.registry?.id,
           }
         )
         .then((res) => {
@@ -150,14 +150,14 @@ export default class ImageList extends Component<PropsType, StateType> {
                 kind: this.props.registry.service,
                 source: img.uri,
                 name: img.name,
-                registryId: this.props.registry.id,
+                registryId: this.props.registry?.id,
               });
             }
             return {
               kind: this.props.registry.service,
               source: img.uri,
               name: img.name,
-              registryId: this.props.registry.id,
+              registryId: this.props.registry?.id,
             };
           });
           images.push(...newImg);

+ 26 - 7
dashboard/src/components/Banner.tsx → dashboard/src/components/porter/Banner.tsx

@@ -9,9 +9,16 @@ interface Props {
   icon?: React.ReactNode;
   children: React.ReactNode;
   noMargin?: boolean;
+  suffix?: React.ReactNode;
 }
 
-const Banner: React.FC<Props> = ({ type, icon, children, noMargin }) => {
+const Banner: React.FC<Props> = ({ 
+  type,
+  icon,
+  children,
+  noMargin,
+  suffix,
+}) => {
   const renderIcon = () => {
     if (icon) {
       return icon;
@@ -28,30 +35,42 @@ const Banner: React.FC<Props> = ({ type, icon, children, noMargin }) => {
       color={type === "error" ? "#ff385d" : type === "warning" && "#f5cb42"}
       noMargin={noMargin}
     >
+      <>
       {renderIcon()}
-      {children}
+      <span>{children}</span>
+      </>
+      {suffix && (
+        <Suffix>{suffix}</Suffix>
+      )}
     </StyledBanner>
   );
 };
 
 export default Banner;
 
+const Suffix = styled.div`
+  margin-left: auto;
+  padding-left: 10px;
+`;
+
 const StyledBanner = styled.div<{
   color?: string;
   noMargin?: boolean;
 }>`
-  height: 40px;
+  min-height: 40px;
   width: 100%;
   margin: ${(props) => (props.noMargin ? "5px 0 10px" : "")};
   font-size: 13px;
   font-family: "Work Sans", sans-serif;
   display: flex;
-  border: 1px solid ${(props) => props.color || "#ffffff00"};
+  line-height: 1.5;
+  border: 1px solid ${(props) => props.color || "#aaaabb"};
   border-radius: 8px;
-  padding: 14px;
-  color: ${(props) => props.color || "#ffffff"};
+  padding: 10px 14px;
+  color: ${(props) => props.color || "#aaaabb"};
   align-items: center;
-  background: #ffffff11;
+  jusrify-content: space-between;
+  background: ${({ theme }) => theme.fg};
   > img {
     margin-right: 10px;
     width: 20px;

+ 34 - 11
dashboard/src/components/porter/Checkbox.tsx

@@ -1,27 +1,47 @@
 import React, { useEffect, useState } from "react";
 import styled from "styled-components";
+import Tooltip from "./Tooltip";
 
 type Props = {
   checked: boolean;
   toggleChecked: () => void;
   children: React.ReactNode;
+  disabled?: boolean;
+  disabledTooltip?: string;
 };
 
 const Checkbox: React.FC<Props> = ({
   checked,
   toggleChecked,
   children,
+  disabled = false,
+  disabledTooltip,
 }) => {
   return (
-    <StyledCheckbox>
-      <Box 
-        checked={checked}
-        onClick={toggleChecked}
-      >
-        <i className="material-icons">done</i>
-      </Box>
-      {children}
-    </StyledCheckbox>
+    disabled && disabledTooltip ?
+      <Tooltip content={disabledTooltip} position="right">
+        <StyledCheckbox>
+          <Box
+            checked={checked}
+            onClick={disabled ? () => { } : toggleChecked}
+            disabled={disabled}
+          >
+            <i className="material-icons">done</i>
+          </Box>
+          {children}
+        </StyledCheckbox>
+      </Tooltip>
+      :
+      <StyledCheckbox>
+        <Box
+          checked={checked}
+          onClick={disabled ? () => { } : toggleChecked}
+          disabled={disabled}
+        >
+          <i className="material-icons">done</i>
+        </Box>
+        {children}
+      </StyledCheckbox>
   );
 };
 
@@ -32,10 +52,12 @@ const StyledCheckbox = styled.div`
   align-items: center;
 `;
 
-const Box = styled.div<{ checked: boolean }>`
+const Box = styled.div<{
+  checked: boolean;
+  disabled?: boolean;
+}>`
   width: 12px;
   height: 12px;
-  cursor: pointer;
   border: 1px solid #ffffff55;
   margin-right: 10px;
   border-radius: 3px;
@@ -43,6 +65,7 @@ const Box = styled.div<{ checked: boolean }>`
   display: flex;
   align-items: center;
   justify-content: center;
+  cursor: ${(props) => (props.disabled ? "not-allowed" : "pointer")};
 
   > i {
     font-size: 12px;

+ 104 - 0
dashboard/src/components/porter/ConfirmOverlay.tsx

@@ -0,0 +1,104 @@
+import React from "react";
+import { createPortal } from "react-dom";
+import styled from "styled-components";
+
+type Props = {
+  message: string;
+  onYes: React.MouseEventHandler;
+  onNo: React.MouseEventHandler;
+};
+
+const TemplateComponent: React.FC<Props> = ({
+  message,
+  onYes,
+  onNo,
+}) => {
+  return (
+    <>
+      {
+        createPortal(
+          <StyledConfirmOverlay>
+            {message}
+            <ButtonRow>
+              <ConfirmButton onClick={onYes}>Yes</ConfirmButton>
+              <ConfirmButton onClick={onNo}>No</ConfirmButton>
+            </ButtonRow>
+          </StyledConfirmOverlay>,
+          document.body
+        )
+      }
+    </>
+  );
+};
+
+export default TemplateComponent;
+
+const StyledConfirmOverlay = styled.div`
+  position: absolute;
+  top: 0px;
+  opacity: 100%;
+  left: 0px;
+  width: 100%;
+  height: 100%;
+  z-index: 999;
+  display: flex;
+  padding-bottom: 30px;
+  align-items: center;
+  justify-content: center;
+  font-family: "Work Sans", sans-serif;
+  font-size: 18px;
+  color: white;
+  flex-direction: column;
+  background: rgb(0, 0, 0, 0.55);
+  backdrop-filter: blur(5px);
+  animation: lindEnter 0.2s;
+  animation-fill-mode: forwards;
+
+  @keyframes lindEnter {
+    from {
+      opacity: 0;
+    }
+    to {
+      opacity: 1;
+    }
+  }
+`;
+
+const ButtonRow = styled.div`
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  width: 140px;
+  margin-top: 30px;
+`;
+
+const ConfirmButton = styled.div`
+  outline: none;
+  height: 40px;
+  border: 1px solid white;
+  border-radius: 5px;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  width: 60px;
+  cursor: pointer;
+  opacity: 0;
+  font-family: "Work Sans", sans-serif;
+  font-size: 15px;
+  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;
+  }
+`;

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

@@ -1,6 +1,7 @@
 import React, { useEffect, useState } from "react";
 import styled from "styled-components";
 import Container from "./Container";
+import CopyToClipboard from "components/CopyToClipboard";
 
 type Props = {
   isInitiallyExpanded?: boolean;
@@ -12,6 +13,8 @@ type Props = {
   expandText?: string;
   collapseText?: string;
   maxHeight?: string;
+  spaced?: boolean;
+  copy?: string;
 };
 
 const ExpandableSection: React.FC<Props> = ({
@@ -24,6 +27,8 @@ const ExpandableSection: React.FC<Props> = ({
   expandText,
   collapseText,
   maxHeight,
+  spaced,
+  copy,
 }) => {
   const [isExpanded, setIsExpanded] = useState(isInitiallyExpanded ?? false);
 
@@ -34,11 +39,31 @@ const ExpandableSection: React.FC<Props> = ({
       noWrapper={noWrapper}
     >
       {noWrapper ? (
-        <Container row>
+        <Container row spaced={spaced}>
           {Header}
-          <ExpandButton onClick={() => setIsExpanded(!isExpanded)}>
-            {isExpanded ? collapseText : expandText}
-          </ExpandButton>
+          {copy ?
+            (
+              <CopyWrapper>
+                <ExpandButton onClick={() => setIsExpanded(!isExpanded)}>
+                  {isExpanded ? collapseText : expandText}
+                </ExpandButton>
+                <CopyToClipboard
+                  as="i"
+                  text={copy}
+                  wrapperProps={{
+                    className: "material-icons",
+                  }}
+                >
+                  content_copy
+                </CopyToClipboard>
+              </CopyWrapper>
+            ) :
+            (
+              <ExpandButton onClick={() => setIsExpanded(!isExpanded)}>
+                {isExpanded ? collapseText : expandText}
+              </ExpandButton>
+            )
+          }
         </Container>
       ) : (
         <HeaderRow
@@ -66,6 +91,9 @@ const ExpandButton = styled.div`
   color: #aaaabb;
   cursor: pointer;
   font-size: 13px;
+  :hover {
+    color: #ffffff;
+  }
 `;
 
 const HeaderRow = styled.div<{
@@ -101,7 +129,7 @@ const StyledExpandableSection = styled.div<{
 }>`
   width: 100%;
   height: ${props => (props.isExpanded || props.noWrapper) ? "" : "40px"};
-  max-height: 300px;
+  max-height: 350px;
   overflow: hidden;
   border-radius: 5px;
   background: ${props => !props.noWrapper && (props.background || "#26292e")};
@@ -119,4 +147,10 @@ const StyledExpandableSection = styled.div<{
       max-height: 300px;
     }
   }
+`;
+
+const CopyWrapper = styled.div`
+  display: flex;
+  align-items: center;
+  gap: 10px;
 `;

+ 59 - 27
dashboard/src/components/porter/Input.tsx

@@ -1,6 +1,7 @@
 import React, { useEffect, useState } from "react";
 import styled from "styled-components";
 import { boolean } from "zod";
+import Tooltip from "./Tooltip";
 
 type Props = {
   placeholder: string;
@@ -13,6 +14,7 @@ type Props = {
   error?: string;
   children?: React.ReactNode;
   disabled?: boolean;
+  disabledTooltip?: string;
 };
 
 const Input: React.FC<Props> = ({
@@ -26,34 +28,64 @@ const Input: React.FC<Props> = ({
   error,
   children,
   disabled,
+  disabledTooltip,
 }) => {
   return (
-    <Block width={width}>
-      {
-        label && (
-          <Label>{label}</Label>
-        )
-      }
-      <StyledInput
-        value={value}
-        onChange={e => setValue(e.target.value)}
-        placeholder={placeholder}
-        width={width}
-        height={height}
-        type={type || "text"}
-        hasError={(error && true) || (error === "")}
-        disabled={disabled ? disabled : false}
-      />
-      {
-        error && (
-          <Error>
-            <i className="material-icons">error</i>
-            {error}
-          </Error>
-        )
-      }
-      {children}
-    </Block>
+    disabled && disabledTooltip ?
+      <Tooltip content={disabledTooltip} position="right">
+        <Block width={width}>
+          {
+            label && (
+              <Label>{label}</Label>
+            )
+          }
+          <StyledInput
+            value={value}
+            onChange={e => setValue(e.target.value)}
+            placeholder={placeholder}
+            width={width}
+            height={height}
+            type={type || "text"}
+            hasError={(error && true) || (error === "")}
+            disabled={disabled ? disabled : false}
+          />
+          {
+            error && (
+              <Error>
+                <i className="material-icons">error</i>
+                {error}
+              </Error>
+            )
+          }
+          {children}
+        </Block>
+      </Tooltip> :
+      <Block width={width} >
+        {
+          label && (
+            <Label>{label}</Label>
+          )
+        }
+        <StyledInput
+          value={value}
+          onChange={e => setValue(e.target.value)}
+          placeholder={placeholder}
+          width={width}
+          height={height}
+          type={type || "text"}
+          hasError={(error && true) || (error === "")}
+          disabled={disabled ? disabled : false}
+        />
+        {
+          error && (
+            <Error>
+              <i className="material-icons">error</i>
+              {error}
+            </Error>
+          )
+        }
+        {children}
+      </Block >
   );
 };
 
@@ -96,7 +128,7 @@ const StyledInput = styled.input<{
   height: ${props => props.height || "35px"};
   padding: 5px 10px;
   width: ${props => props.width || "200px"};
-  color: #ffffff;
+  color: ${props => props.disabled ? "#aaaabb" : "#ffffff"};
   font-size: 13px;
   outline: none;
   border-radius: 5px;

+ 21 - 6
dashboard/src/components/porter/Link.tsx

@@ -7,6 +7,7 @@ type Props = {
   onClick?: () => void;
   children: React.ReactNode;
   target?: string;
+  hasunderline?: boolean;
 };
 
 const Link: React.FC<Props> = ({
@@ -14,13 +15,25 @@ const Link: React.FC<Props> = ({
   onClick,
   children,
   target,
+  hasunderline,
 }) => {
   return (
     <>
       {to ? (
-        <StyledLink to={to} target={target}>{children}</StyledLink>
+        <StyledLink 
+          to={to} 
+          target={target}
+          hasunderline={hasunderline}
+        >
+          {children}
+        </StyledLink>
       ) : (
-        <Div onClick={onClick}>{children}</Div>
+        <Div 
+          onClick={onClick}
+          hasunderline={hasunderline}
+        >
+          {children}
+        </Div>
       )}
     </>
   );
@@ -28,14 +41,16 @@ const Link: React.FC<Props> = ({
 
 export default Link;
 
-const Div = styled.span`
-  color: #8590ff;
+const Div = styled.span<{ hasunderline?: boolean }>`
+  color: #ffffff;
   cursor: pointer;
   display: inline;
+  text-decoration: ${props => props.hasunderline ? "underline" : ""};
 `;
 
-const StyledLink = styled(DynamicLink)`
-  color: #8590ff;
+const StyledLink = styled(DynamicLink)<{ hasunderline?: boolean }>`
+  color: #ffffff;
   display: inline;
   cursor: pointer;
+  text-decoration: ${props => props.hasunderline ? "underline" : ""};
 `;

+ 8 - 4
dashboard/src/components/porter/Modal.tsx

@@ -5,11 +5,13 @@ import { createPortal } from "react-dom";
 type Props = {
   closeModal?: () => void;
   children: React.ReactNode;
+  width?: string;
 };
 
 const Modal: React.FC<Props> = ({
   closeModal,
   children,
+  width,
 }) => {
   return (
     <>
@@ -17,7 +19,7 @@ const Modal: React.FC<Props> = ({
         createPortal(
           <ModalWrapper>
             <ModalBg onClick={closeModal} />
-            <StyledModal> 
+            <StyledModal width={width}> 
               {closeModal && (
                 <CloseButton onClick={closeModal}>
                   <i className="material-icons">close</i>
@@ -92,14 +94,16 @@ const ModalBg = styled.div`
   }
 `;
 
-const StyledModal = styled.div`
+const StyledModal = styled.div<{
+  width?: string;
+}>`
   position: relative;
   padding: 25px;
-  padding-bottom: 35px;
+  padding-bottom: 30px;
   border-radius: 10px;
   border: 1px solid #494b4f;
   font-size: 13px;
-  width: 600px;
+  width: ${props => props.width || "600px"};
   background: #42444933;
   backdrop-filter: saturate(150%) blur(8px);
 

+ 21 - 22
dashboard/src/components/porter/Select.tsx

@@ -26,35 +26,34 @@ const Select: React.FC<Props> = ({
 }) => {
   return (
     <Block width={width}>
-      {
-        label && (
-          <Label>{label}</Label>
-        )
-      }
+      {label && <Label>{label}</Label>}
       <SelectWrapper>
         <i className="material-icons">arrow_drop_down</i>
         <StyledSelect
-          onChange={e => {
+          onChange={(e) => {
             setValue(e.target.value);
           }}
           width={width}
           height={height}
-          hasError={(error && true) || (error === "")}
+          hasError={(error && true) || error === ""}
           disabled={disabled ? disabled : false}
+          value={value}
         >
           {options.map((option, i) => {
-            return <option value={option.value} key={i}>{option.label}</option>;
+            return (
+              <option value={option.value} key={i}>
+                {option.label}
+              </option>
+            );
           })}
         </StyledSelect>
       </SelectWrapper>
-      {
-        error && (
-          <Error>
-            <i className="material-icons">error</i>
-            {error}
-          </Error>
-        )
-      }
+      {error && (
+        <Error>
+          <i className="material-icons">error</i>
+          {error}
+        </Error>
+      )}
       {children}
     </Block>
   );
@@ -67,7 +66,7 @@ const Block = styled.div<{
 }>`
   display: block;
   position: relative;
-  width: ${props => props.width || "200px"};
+  width: ${(props) => props.width || "200px"};
 `;
 
 const Label = styled.div`
@@ -109,9 +108,9 @@ const StyledSelect = styled.select<{
   height: string;
   hasError: boolean;
 }>`
-  height: ${props => props.height || "35px"};
+  height: ${(props) => props.height || "35px"};
   padding: 5px 10px;
-  width: ${props => props.width || "200px"};
+  width: ${(props) => props.width || "200px"};
   color: #ffffff;
   font-size: 13px;
   outline: none;
@@ -121,8 +120,8 @@ const StyledSelect = styled.select<{
   appearance: none;
   overflow: hidden;
   z-index: 1;
-  border: 1px solid ${props => props.hasError ? "#ff3b62" : "#494b4f"};
+  border: 1px solid ${(props) => (props.hasError ? "#ff3b62" : "#494b4f")};
   :hover {
-    border: 1px solid ${props => props.hasError ? "#ff3b62" : "#7a7b80"};
+    border: 1px solid ${(props) => (props.hasError ? "#ff3b62" : "#7a7b80")};
   }
-`;
+`;

+ 2 - 1
dashboard/src/components/porter/Toggle.tsx

@@ -16,8 +16,9 @@ const Toggle: React.FC<Props> = ({
 }) => {
   return (
     <StyledToggle>
-      {items.map((item, index) => (
+      {items.map((item, i) => (
         <Item
+          key={i}
           active={item.value === active}
           onClick={() => {
             setActive(item.value);

+ 7 - 4
dashboard/src/components/porter/Tooltip.tsx

@@ -7,6 +7,7 @@ interface TooltipProps {
   content: React.ReactNode;
   position?: "top" | "right" | "bottom" | "left";
   hidden?: boolean;
+  width?: string;
 }
 
 const Tooltip: React.FC<TooltipProps> = ({
@@ -14,6 +15,7 @@ const Tooltip: React.FC<TooltipProps> = ({
   content,
   position = "top",
   hidden = false,
+  width,
 }) => {
   const [isVisible, setIsVisible] = useState(false);
 
@@ -27,7 +29,7 @@ const Tooltip: React.FC<TooltipProps> = ({
   return (
     <TooltipContainer onMouseEnter={showTooltip} onMouseLeave={hideTooltip}>
       {isVisible && (
-        <TooltipContent position={position}>{content}</TooltipContent>
+        <TooltipContent position={position} width={width}>{content}</TooltipContent>
       )}
       {children}
     </TooltipContainer>
@@ -41,7 +43,7 @@ const TooltipContainer = styled.div`
   display: inline-flex;
 `;
 
-const TooltipContent = styled.div<{ position: string }>`
+const TooltipContent = styled.div<{ position: string, width?: string }>`
   background-color: #333;
   color: #fff;
   padding: 8px;
@@ -49,7 +51,8 @@ const TooltipContent = styled.div<{ position: string }>`
   font-size: 14px;
   position: absolute;
   z-index: 10;
-  max-width: 200px;
+  max-width: ${({ width }) => width ?? "200px"};
+  width: ${({ width }) => width ?? "200px"};
   text-align: center;
   white-space: pre-wrap;
   word-wrap: break-word;
@@ -64,7 +67,7 @@ const TooltipContent = styled.div<{ position: string }>`
         `;
       case "right":
         return `
-          top: 50%;
+          top: 70%;
           left: 100%;
           transform: translateY(-50%) translateX(8px);
         `;

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

@@ -16,7 +16,7 @@ const VerticalSteps: React.FC<Props> = ({
     <StyledVerticalSteps>
       {steps.map((step, i) => {
         return (
-          <StepWrapper isLast={i === steps.length - 1}>
+          <StepWrapper isLast={i === steps.length - 1} key={i}>
             {
               (i !== steps.length - 1) && (
                 <Line isActive={i + 1 <= currentStep} />

+ 32 - 12
dashboard/src/components/repo-selector/ActionConfBranchSelector.tsx

@@ -5,6 +5,8 @@ import { ActionConfigType } from "shared/types";
 
 import RepoList from "./RepoList";
 import BranchList from "./BranchList";
+import ContentsList from "./ContentsList";
+import ActionDetails from "./ActionDetails";
 import InputRow from "../form-components/InputRow";
 
 type Props = {
@@ -12,18 +14,15 @@ type Props = {
   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,
-}) => {
+const ActionConfEditorStack: React.FC<Props> = (props) => {
+  const { actionConfig, setBranch, setActionConfig, branch } = props;
+
   if (!actionConfig.git_repo) {
     return (
       <ExpandedWrapperAlt>
@@ -35,7 +34,7 @@ const ActionConfEditorStack: React.FC<Props> = ({
       </ExpandedWrapperAlt>
     );
   } else if (!branch) {
-    setFolderPath("./");
+    props.setFolderPath("./");
     return (
       <>
         <ExpandedWrapperAlt>
@@ -55,14 +54,15 @@ const ActionConfEditorStack: React.FC<Props> = ({
         label="Branch"
         type="text"
         width="100%"
-        value={branch}
+        value={props?.branch}
       />
       <BackButton
         width="145px"
         onClick={() => {
-          setFolderPath("");
           setBranch("");
-          setDockerfilePath("");
+          props.setFolderPath("");
+          props.setDockerfilePath("");
+          props.setActionConfig(actionConfig);
         }}
       >
         <i className="material-icons">keyboard_backspace</i>
@@ -79,6 +79,26 @@ const Br = styled.div`
   height: 8px;
 `;
 
+const Flex = styled.div`
+  display: flex;
+  align-items: center;
+`;
+
+const HeaderButton = styled.div`
+  margin-bottom: 5px;
+  padding: 5px 10px;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  font-weight: 500;
+  margin-right: 10px;
+`;
+
+const RepoHeader = styled.div`
+  display: flex;
+  align-items: center;
+`;
+
 const ExpandedWrapper = styled.div`
   margin-top: 10px;
   width: 100%;

+ 0 - 1
dashboard/src/components/repo-selector/BuildpackSelection.tsx

@@ -60,7 +60,6 @@ export const BuildpackSelection: React.FC<{
 
   useEffect(() => {
     let buildConfig: BuildConfig = {} as BuildConfig;
-
     buildConfig.builder = selectedStack;
     buildConfig.buildpacks = selectedBuildpacks?.map((buildpack) => {
       return buildpack.buildpack;

+ 169 - 94
dashboard/src/components/repo-selector/BuildpackStack.tsx

@@ -1,7 +1,7 @@
 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 Select from "components/porter/Select";
 import Loading from "components/Loading";
 import React, { useContext, useEffect, useMemo, useState } from "react";
 import api from "shared/api";
@@ -11,6 +11,10 @@ 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";
+import Button from "components/porter/Button";
+import Modal from "components/porter/Modal";
+import Spacer from "components/porter/Spacer";
+import Text from "components/porter/Text";
 
 const DEFAULT_BUILDER_NAME = "heroku";
 const DEFAULT_PAKETO_STACK = "paketobuildpacks/builder:full";
@@ -37,6 +41,7 @@ type DetectedBuildpack = {
   builders: string[];
   detected: Buildpack[];
   others: Buildpack[];
+  buildConfig: BuildConfig;
 };
 
 type DetectBuildpackResponse = DetectedBuildpack[];
@@ -47,14 +52,25 @@ export const BuildpackStack: React.FC<{
   branch: string;
   hide: boolean;
   onChange: (config: BuildConfig) => void;
-}> = ({ actionConfig, folderPath, branch, hide, onChange }) => {
+  currentBuildConfig?: BuildConfig;
+  setBuildConfig?: (config: BuildConfig) => void;
+}> = ({
+  actionConfig,
+  folderPath,
+  branch,
+  hide,
+  onChange,
+  currentBuildConfig,
+  setBuildConfig,
+}) => {
   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 [selectedStack, setSelectedStack] = useState<string>(
+    currentBuildConfig?.builder || null
+  );
   const [isModalOpen, setIsModalOpen] = useState(false);
 
   const [selectedBuildpacks, setSelectedBuildpacks] = useState<Buildpack[]>([]);
@@ -63,46 +79,38 @@ export const BuildpackStack: React.FC<{
   );
   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>
+      <>
+        <Text size={16}>Buildpack Configuration</Text>
+        <Spacer y={1} />
+        <Scrollable>
+          <Text color="helper">Selected buildpacks:</Text>
+          <Spacer y={1} />
+          {!!selectedBuildpacks?.length &&
+            renderBuildpacksList(selectedBuildpacks, "remove")}
+
+          <Spacer y={1} />
+          {!!availableBuildpacks?.length && (
+            <>
+              <Text color="helper">Available buildpacks:</Text>
+              <Spacer y={1} />
+              <>{renderBuildpacksList(availableBuildpacks, "add")}</>
+            </>
+          )}
+          <Spacer y={1} />
+          <Text color="helper">
+            You may also add buildpacks by directly providing their GitHub links
+            or links to ZIP files that contain the buildpack source code.
+          </Text>
+          <Spacer y={1} />
+          <AddCustomBuildpackForm onAdd={handleAddCustomBuildpack} />
+          <Spacer y={2} />
+        </Scrollable>
+        <Footer>
+          <Shade />
+          <Spacer y={1} />
+          <Button onClick={() => setIsModalOpen(false)}>Save buildpacks</Button>
+        </Footer>
+      </>
     );
   };
   useEffect(() => {
@@ -112,10 +120,15 @@ export const BuildpackStack: React.FC<{
     buildConfig.buildpacks = selectedBuildpacks?.map((buildpack) => {
       return buildpack.buildpack;
     });
+
     if (typeof onChange === "function") {
       onChange(buildConfig);
+
+      if (currentBuildConfig) {
+        setBuildConfig(buildConfig);
+      }
     }
-  }, [selectedBuilder, selectedStack, selectedBuildpacks]);
+  }, [selectedStack, selectedBuildpacks]);
 
   const detectBuildpack = () => {
     if (actionConfig.kind === "gitlab") {
@@ -161,16 +174,56 @@ export const BuildpackStack: React.FC<{
           (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
+        var detectedBuildpacks = defaultBuilder.detected;
+        var availableBuildpacks = defaultBuilder.others;
+        var defaultStack = "";
+        if (currentBuildConfig) {
+          if (!detectedBuildpacks) {
+            detectedBuildpacks = [];
+          }
+
+          defaultStack = currentBuildConfig.builder;
+          for (const buildpackName of currentBuildConfig.buildpacks) {
+            const matchingBuildpackIndex = availableBuildpacks.findIndex(
+              (buildpack) => buildpack.buildpack === buildpackName
             );
-          });
 
+            if (matchingBuildpackIndex >= 0) {
+              const matchingBuildpack = availableBuildpacks.splice(
+                matchingBuildpackIndex,
+                1
+              )[0];
+              const existingBuildpackIndex = detectedBuildpacks.findIndex(
+                (buildpack) => buildpack.buildpack === buildpackName
+              );
+              if (existingBuildpackIndex < 0) {
+                detectedBuildpacks.push(matchingBuildpack);
+              }
+            } else {
+              const newBuildpack: Buildpack = {
+                name: buildpackName,
+                buildpack: buildpackName,
+                config: null,
+              };
+              const existingBuildpackIndex = detectedBuildpacks.findIndex(
+                (buildpack) => buildpack.buildpack === buildpackName
+              );
+              if (existingBuildpackIndex < 0) {
+                detectedBuildpacks.push(newBuildpack);
+              }
+            }
+          }
+        } else {
+          detectedBuildpacks = defaultBuilder.detected;
+          availableBuildpacks = defaultBuilder.others;
+          defaultStack = builders
+            .flatMap((builder) => builder.builders)
+            .find((stack) => {
+              return (
+                stack === DEFAULT_HEROKU_STACK || stack === DEFAULT_PAKETO_STACK
+              );
+            });
+        }
         setBuilders(builders);
         setSelectedStack(defaultStack);
 
@@ -264,7 +317,7 @@ export const BuildpackStack: React.FC<{
       }
 
       return (
-        <StyledCard key={buildpack.name}>
+        <StyledCard key={buildpack.name} marginBottom="5px">
           <ContentContainer>
             <Icon disableMarginRight={disableIcon} className={icon} />
             <EventInformation>
@@ -348,12 +401,14 @@ export const BuildpackStack: React.FC<{
   return (
     <BuildpackConfigurationContainer>
       <>
-        <SelectRow
+        <Select
           value={selectedStack}
-          width="100%"
+          width="300px"
           options={stackOptions}
-          setActiveValue={(option) => setSelectedStack(option)}
-          label="Select your builder and stack"
+          setValue={(option) => {
+            setSelectedStack(option);
+          }}
+          label="Builder and stack"
         />
         {!!selectedBuildpacks?.length && (
           <Helper>
@@ -361,27 +416,18 @@ export const BuildpackStack: React.FC<{
             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>
+        {!!selectedBuildpacks?.length && (
+          <>{renderBuildpacksList(selectedBuildpacks, "remove")}</>
+        )}
+        <Spacer y={1} />
+        <Button onClick={() => setIsModalOpen(true)}>
+          <I className="material-icons">add</I> Add buildpack
+        </Button>
+        {isModalOpen && (
+          <Modal closeModal={() => setIsModalOpen(false)}>
+            {renderModalContent()}
+          </Modal>
+        )}
       </>
     </BuildpackConfigurationContainer>
   );
@@ -404,7 +450,7 @@ export const AddCustomBuildpackForm: React.FC<{
   };
 
   return (
-    <StyledCard isLargeMargin>
+    <StyledCard marginBottom="0px">
       <ContentContainer>
         <EventInformation>
           <BuildpackInputContainer>
@@ -432,6 +478,37 @@ export const AddCustomBuildpackForm: React.FC<{
   );
 };
 
+const Shade = styled.div`
+  position: absolute;
+  top: -50px;
+  left: 0;
+  height: 50px;
+  width: 100%;
+  background: linear-gradient(to bottom, #00000000, ${({ theme }) => theme.fg});
+`;
+
+const Footer = styled.div` 
+  position: relative;
+  width: calc(100% + 50px);
+  margin-left: -25px;
+  padding: 0 25px;
+  border-bottom-left-radius: 10px;
+  border-bottom-right-radius: 10px;
+  background: ${({ theme }) => theme.fg};
+  margin-bottom: -30px;
+  padding-bottom: 30px
+
+`;
+
+const I = styled.i`
+  color: white;
+  font-size: 14px;
+  display: flex;
+  align-items: center;
+  margin-right: 5px;
+  justify-content: center;
+`;
+
 const ErrorText = styled.span`
   color: red;
   margin-left: 10px;
@@ -439,6 +516,14 @@ const ErrorText = styled.span`
     props.hasError ? "inline-block" : "none"};
 `;
 
+const Scrollable = styled.div`
+  overflow-y: auto;
+  padding: 0 25px;
+  width: calc(100% + 50px);
+  margin-left: -25px;
+  max-height: calc(100vh - 300px);
+`;
+
 const fadeIn = keyframes`
   from {
     opacity: 0;
@@ -461,14 +546,13 @@ const BuildpackConfigurationContainer = styled.div`
   animation: ${fadeIn} 0.75s;
 `;
 
-const StyledCard = styled.div`
+const StyledCard = styled.div<{ marginBottom?: string }>`
   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: 1px solid #494b4f;
+  background: ${({ theme }) => theme.fg};
+  margin-bottom: ${(props) => props.marginBottom || "30px"};
   border-radius: 8px;
   padding: 14px;
   overflow: hidden;
@@ -541,15 +625,6 @@ const ActionButton = styled.button`
   }
 `;
 
-const AddBuildPackButton = withStyles({
-  root: {
-    backgroundColor: "#8590ff",
-    color: "white",
-    marginBottom: "15px",
-    marginTop: "10px",
-  },
-})(MuiButton);
-
 const SaveButton = withStyles({
   root: {
     backgroundColor: "#8590ff",

+ 21 - 55
dashboard/src/components/repo-selector/DetectContentsList.tsx

@@ -7,7 +7,7 @@ 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 { ActionConfigType, BuildConfig, FileType } from "../../shared/types";
 
 import Loading from "../Loading";
 import Spacer from "components/porter/Spacer";
@@ -25,12 +25,9 @@ type PropsType = {
   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;
@@ -66,7 +63,6 @@ const DetectContentsList: React.FC<PropsType> = (props) => {
     if (porterYamlItem) {
       fetchAndSetPorterYaml("porter.yaml");
     }
-
   }, [contents, fetchAndSetPorterYaml]);
 
   useEffect(() => {
@@ -90,33 +86,15 @@ const DetectContentsList: React.FC<PropsType> = (props) => {
   }, [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) => {
+    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}
-          />
-        );
+        return false;
       }
     });
+
+    return true;
   };
 
   const fetchContents = () => {
@@ -205,19 +183,6 @@ const DetectContentsList: React.FC<PropsType> = (props) => {
         }
       );
     }
-
-    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 () => {
@@ -258,19 +223,20 @@ const DetectContentsList: React.FC<PropsType> = (props) => {
   };
   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}
-        />
-      ) : (
-        <></>
+      {renderContentList() && (
+        <>
+          <AdvancedBuildSettings
+            dockerfilePath={props.dockerfilePath}
+            setDockerfilePath={props.setDockerfilePath}
+            setBuildConfig={props.setBuildConfig}
+            autoBuildPack={autoBuildpack}
+            showSettings={false}
+            buildView={props.dockerfilePath ? "dockerfile" : "buildpacks"}
+            actionConfig={props.actionConfig}
+            branch={props.branch}
+            folderPath={props.folderPath}
+          />
+        </>
       )}
     </>
   );
@@ -457,7 +423,7 @@ const Item = styled.div`
   font-size: 13px;
   border-bottom: 1px solid
     ${(props: { lastItem: boolean; isSelected?: boolean }) =>
-    props.lastItem ? "#00000000" : "#606166"};
+      props.lastItem ? "#00000000" : "#606166"};
   color: #ffffff;
   user-select: none;
   align-items: center;
@@ -488,7 +454,7 @@ const FileItem = styled(Item)`
     props.isADocker ? "#fff" : "#ffffff55"};
   :hover {
     background: ${(props: { isADocker?: boolean }) =>
-    props.isADocker ? "#ffffff22" : "#ffffff11"};
+      props.isADocker ? "#ffffff22" : "#ffffff11"};
   }
 `;
 

+ 27 - 33
dashboard/src/main/home/Home.tsx

@@ -191,7 +191,7 @@ const Home: React.FC<Props> = (props) => {
       } else {
         setHasFinishedOnboarding(true);
       }
-    } catch (error) { }
+    } catch (error) {}
   };
 
   useEffect(() => {
@@ -365,7 +365,9 @@ const Home: React.FC<Props> = (props) => {
 
   const { cluster, baseRoute } = props.match.params as any;
   return (
-    <ThemeProvider theme={currentProject?.simplified_view_enabled ? midnight : standard}>
+    <ThemeProvider
+      theme={currentProject?.simplified_view_enabled ? midnight : standard}
+    >
       <StyledHome>
         <ModalHandler setRefreshClusters={setForceRefreshClusters} />
         {currentOverlay &&
@@ -401,27 +403,19 @@ const Home: React.FC<Props> = (props) => {
           />
 
           <Switch>
-            <Route
-              path="/apps/new"
-            >
+            <Route path="/apps/new/app">
               <NewAppFlow />
             </Route>
             <Route path="/apps/:appName">
               <ExpandedApp />
             </Route>
-            <Route
-              path="/apps"
-            >
+            <Route path="/apps">
               <AppDashboard />
             </Route>
-            <Route
-              path="/addons/new"
-            >
+            <Route path="/addons/new">
               <NewAddOnFlow />
             </Route>
-            <Route
-              path="/addons"
-            >
+            <Route path="/addons">
               <AddOnDashboard />
             </Route>
             <Route
@@ -440,17 +434,17 @@ const Home: React.FC<Props> = (props) => {
               overrideInfraTabEnabled({
                 projectID: currentProject?.id,
               })) && (
-                <Route
-                  path="/infrastructure"
-                  render={() => {
-                    return (
-                      <DashboardWrapper>
-                        <InfrastructureRouter />
-                      </DashboardWrapper>
-                    );
-                  }}
-                />
-              )}
+              <Route
+                path="/infrastructure"
+                render={() => {
+                  return (
+                    <DashboardWrapper>
+                      <InfrastructureRouter />
+                    </DashboardWrapper>
+                  );
+                }}
+              />
+            )}
             <Route
               path="/dashboard"
               render={() => {
@@ -519,26 +513,26 @@ const Home: React.FC<Props> = (props) => {
           />,
           document.body
         )}
-        {showWrongEmailModal &&
+        {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.
+              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.
+              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>
+            <Button onClick={props.logOut}>Log out</Button>
           </Modal>
-        }
+        )}
       </StyledHome>
     </ThemeProvider>
   );

+ 6 - 3
dashboard/src/main/home/add-on-dashboard/AddOnDashboard.tsx

@@ -116,7 +116,10 @@ const AppDashboard: React.FC<Props> = ({
   };
 
   useEffect(() => {
-    getAddOns();
+    // currentCluster sometimes returns as -1 and passes null check
+    if (currentProject?.id >= 0 && currentCluster?.id >= 0) {
+      getAddOns();
+    }
   }, [currentCluster, currentProject]);
 
   const getExpandedChartLinkURL = useCallback((x: any) => {
@@ -181,7 +184,7 @@ const AppDashboard: React.FC<Props> = ({
         <GridList>
           {(filteredAddOns ?? []).map((app: any, i: number) => {
             return (
-              <Block to={getExpandedChartLinkURL(app)}>
+              <Block to={getExpandedChartLinkURL(app)} key={i}>
                 <Text size={14}>
                   <Icon 
                     src={
@@ -204,7 +207,7 @@ const AppDashboard: React.FC<Props> = ({
         <List>
           {(filteredAddOns ?? []).map((app: any, i: number) => {
             return (
-              <Row to={getExpandedChartLinkURL(app)}>
+              <Row to={getExpandedChartLinkURL(app)} key={i}>
                 <Text size={14}>
                   <MidIcon
                     src={

+ 183 - 72
dashboard/src/main/home/app-dashboard/AppDashboard.tsx

@@ -1,17 +1,21 @@
 import React, { useEffect, useState, useContext, useMemo } from "react";
 import styled from "styled-components";
 import _ from "lodash";
+import { Link, LinkProps } from "react-router-dom";
 
 import web from "assets/web.png";
+import box from "assets/box.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 { readableDate } from "shared/string_utils";
 
 import DashboardHeader from "../cluster-dashboard/DashboardHeader";
 import Container from "components/porter/Container";
@@ -20,10 +24,11 @@ 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";
+import PorterLink from "components/porter/Link";
+import Loading from "components/Loading";
+import Fieldset from "components/porter/Fieldset";
 
-type Props = {
-};
+type Props = {};
 
 const icons = [
   "https://cdn.jsdelivr.net/gh/devicons/devicon/icons/ruby/ruby-plain.svg",
@@ -43,47 +48,129 @@ const namespaceBlacklist = [
   "monitoring",
 ];
 
-const AppDashboard: React.FC<Props> = ({
-}) => {
+const AppDashboard: React.FC<Props> = ({}) => {
   const { currentProject, currentCluster } = useContext(Context);
   const [apps, setApps] = useState([]);
+  const [charts, setCharts] = useState([]);
+  const [error, setError] = useState(null);
   const [searchValue, setSearchValue] = useState("");
   const [view, setView] = useState("grid");
   const [isLoading, setIsLoading] = useState(true);
+  const [shouldLoadTime, setShouldLoadTime] = useState(true);
 
   const filteredApps = useMemo(() => {
-    const filteredBySearch = search(
-      apps ?? [],
-      searchValue,
-      {
-        keys: ["name"],
-        isCaseSensitive: false,
-      }
-    );
+    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)
+    setIsLoading(true);
     try {
-      const res = await api.getNamespaces(
+      const res = await api.getPorterApps(
         "<token>",
         {},
         {
-          id: currentProject.id,
+          project_id: currentProject.id,
           cluster_id: currentCluster.id,
         }
-      )
-      setApps(res.data);
+      );
+      const apps = res.data;
+      const timeRes = await Promise.all(
+        apps.map((app: any) => {
+          return api.getCharts(
+            "<token>",
+            {
+              limit: 1,
+              skip: 0,
+              byDate: false,
+              statusFilter: [
+                "deployed",
+                "uninstalled",
+                "pending",
+                "pending-install",
+                "pending-upgrade",
+                "pending-rollback",
+                "failed",
+              ],
+            },
+            {
+              id: currentProject.id,
+              cluster_id: currentCluster.id,
+              namespace: `porter-stack-${app.name}`,
+            }
+          );
+        })
+      );
+      apps.forEach((app: any, i: number) => {
+        app["last_deployed"] = readableDate(
+          timeRes[i].data[0]?.info?.last_deployed
+        );
+      });
+      setApps(apps.reverse());
+      setIsLoading(false);
+    } catch (err) {
+      setError(err);
+      setIsLoading(false);
     }
-    catch (err) {}
   };
 
   useEffect(() => {
-    getApps();
-  }, []);
+    if (currentProject?.id > 0 && currentCluster?.id > 0) {
+      getApps();
+    }
+  }, [currentCluster, currentProject]);
+
+  const renderSource = (app: any) => {
+    return (
+      <>
+        {app.repo_name ? (
+          <>
+            <SmallIcon opacity="0.6" src={github} />
+            {app.repo_name}
+          </>
+        ) : (
+          <>
+            <SmallIcon
+              opacity="0.7"
+              height="18px"
+              src="https://cdn4.iconfinder.com/data/icons/logos-and-brands/512/97_Docker_logo_logos-512.png"
+            />
+            {app.image_repo_uri}
+          </>
+        )}
+      </>
+    );
+  };
+
+  const renderIcon = (b: string, size?: string) => {
+    var src = box;
+    if (b) {
+      const bp = b.split(",")[0]?.split("/")[1];
+      switch (bp) {
+        case "ruby":
+          src = icons[0];
+          break;
+        case "nodejs":
+          src = icons[1];
+          break;
+        case "python":
+          src = icons[2];
+          break;
+        case "go":
+          src = icons[3];
+          break;
+        default:
+          break;
+      }
+    }
+    return (
+      <>{size === "larger" ? <MidIcon src={src} /> : <Icon src={src} />}</>
+    );
+  };
 
   return (
     <StyledAppDashboard>
@@ -94,7 +181,7 @@ const AppDashboard: React.FC<Props> = ({
         disableLineBreak
       />
       <Container row spaced>
-        <SearchBar 
+        <SearchBar
           value={searchValue}
           setValue={setSearchValue}
           placeholder="Search applications . . ."
@@ -110,58 +197,73 @@ const AppDashboard: React.FC<Props> = ({
           setActive={setView}
         />
         <Spacer inline x={2} />
-        <Link to="/apps/new">
+        <PorterLink to="/apps/new/app">
           <Button onClick={() => {}} height="30px" width="160px">
             <I className="material-icons">add</I> New application
           </Button>
-        </Link>
+        </PorterLink>
       </Container>
       <Spacer y={1} />
-      {view === "grid" ? (
+      {!isLoading && filteredApps.length === 0 && (
+        <Fieldset>
+          <Container row>
+            <PlaceholderIcon src={notFound} />
+            <Text color="helper">No applications were found.</Text>
+          </Container>
+        </Fieldset>
+      )}
+      {isLoading ? (
+        <Loading offset="-150px" />
+      ) : 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>
+          {(filteredApps ?? []).map((app: any, i: number) => {
+            if (!namespaceBlacklist.includes(app.name)) {
+              return (
+                <Link to={`/apps/${app.name}`} key={i}>
+                  <Block>
+                    <Container row>
+                      <Text size={14}>
+                        {renderIcon(app["build_packs"])}
+                        {app.name}
+                      </Text>
+                      <Spacer inline x={2} />
+                    </Container>
+                    <StatusIcon src={healthy} />
+                    <Text size={13} color="#ffffff44">
+                      {renderSource(app)}
+                    </Text>
+                    <Text size={13} color="#ffffff44">
+                      <SmallIcon opacity="0.4" src={time} />
+                      {app.last_deployed}
+                    </Text>
+                  </Block>
+                </Link>
+              );
+            }
+          })}
+        </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>
+                <Link to={`/apps/${app.name}`} key={i}>
+                  <Row>
+                    <Text size={14}>
+                      {renderIcon(app["build_packs"], "larger")}
+                      {app.name}
+                      <Spacer inline x={1} />
+                      <MidIcon src={healthy} />
+                    </Text>
+                    <Spacer height="15px" />
+                    <Text size={13} color="#ffffff44">
+                      {renderSource(app)}
+                      <Spacer inline x={1} />
+                      <SmallIcon opacity="0.4" src={time} />
+                      {app.last_deployed}
+                    </Text>
+                  </Row>
+                </Link>
               );
             }
           })}
@@ -174,11 +276,18 @@ const AppDashboard: React.FC<Props> = ({
 
 export default AppDashboard;
 
+const PlaceholderIcon = styled.img`
+  height: 13px;
+  margin-right: 12px;
+  opacity: 0.65;
+`;
+
 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};
+  border-bottom: ${(props) =>
+    props.isAtBottom ? "none" : "1px solid #494b4f"};
+  background: ${(props) => props.theme.clickable.bg};
   position: relative;
   border: 1px solid #494b4f;
   border-radius: 5px;
@@ -211,12 +320,14 @@ const Icon = styled.img`
 const MidIcon = styled.img`
   height: 16px;
   margin-right: 13px;
+  margin-left: 1px;
 `;
 
-const SmallIcon = styled.img<{ opacity?: string }>`
+const SmallIcon = styled.img<{ opacity?: string; height?: string }>`
   margin-left: 2px;
-  height: 14px;
-  opacity: ${props => props.opacity || 1};
+  height: ${(props) => props.height || "14px"};
+  opacity: ${(props) => props.opacity || 1};
+  filter: grayscale(100%);
   margin-right: 10px;
 `;
 
@@ -227,10 +338,10 @@ const Block = styled.div`
   justify-content: space-between;
   cursor: pointer;
   padding: 20px;
-  color: ${props => props.theme.text.primary};
+  color: ${(props) => props.theme.text.primary};
   position: relative;
   border-radius: 5px;
-  background: ${props => props.theme.clickable.bg};
+  background: ${(props) => props.theme.clickable.bg};
   border: 1px solid #494b4f;
   :hover {
     border: 1px solid #7a7b80;
@@ -266,4 +377,4 @@ const I = styled.i`
 const StyledAppDashboard = styled.div`
   width: 100%;
   height: 100%;
-`;
+`;

+ 43 - 0
dashboard/src/main/home/app-dashboard/expanded-app/AppEvents.tsx

@@ -0,0 +1,43 @@
+import Loading from "components/Loading";
+import Fieldset from "components/porter/Fieldset";
+import Link from "components/porter/Link";
+import Spacer from "components/porter/Spacer";
+import Text from "components/porter/Text";
+import React, { useEffect, useState } from "react";
+import styled from "styled-components";
+
+type Props = {
+  repoName: string; 
+  branchName: string;
+};
+
+const AppEvents: React.FC<Props> = ({
+  repoName,
+  branchName,
+}) => {
+  const [isExpanded, setIsExpanded] = useState(false);
+
+  useEffect(() => {
+    // Do something
+  }, []);
+
+  return (
+    <StyledAppEvents>
+      <Fieldset>
+        <Text size={16}>
+          Dream on
+        </Text>
+        <Spacer y={0.5} />
+        <Text color="helper">
+          Lorem ipsum dolor sit amet, consectetur adipiscing elit.
+        </Text>
+        <Spacer height="10px" />
+      </Fieldset>
+    </StyledAppEvents>
+  );
+};
+
+export default AppEvents;
+
+const StyledAppEvents = styled.div`
+`;

+ 447 - 0
dashboard/src/main/home/app-dashboard/expanded-app/BuildSettingsTabStack.tsx

@@ -0,0 +1,447 @@
+import AnimateHeight from "react-animate-height";
+import React, {
+  Component,
+  Dispatch,
+  useContext,
+  useEffect,
+  useMemo,
+  useRef,
+  useState,
+} from "react";
+import Text from "components/porter/Text";
+import Spacer from "components/porter/Spacer";
+import Input from "components/porter/Input";
+import AdvancedBuildSettings from "../new-app-flow/AdvancedBuildSettings";
+import styled from "styled-components";
+import { SourceType } from "../new-app-flow/SourceSelector";
+import ActionConfEditorStack from "components/repo-selector/ActionConfEditorStack";
+import {
+  ActionConfigType,
+  BuildConfig,
+  FullActionConfigType,
+  GithubActionConfigType,
+} 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";
+import { pushFiltered } from "shared/routing";
+import ImageSelector from "components/image-selector/ImageSelector";
+import SharedBuildSettings from "./SharedBuildSettings";
+import { BuildpackSelection } from "components/repo-selector/BuildpackSelection";
+import BuildpackConfigSection from "main/home/cluster-dashboard/expanded-chart/build-settings/_BuildpackConfigSection";
+import { BuildpackStack } from "components/repo-selector/BuildpackStack";
+import MultiSaveButton from "components/MultiSaveButton";
+import api from "shared/api";
+import { AxiosError } from "axios";
+import InputRow from "components/form-components/InputRow";
+import Loading from "components/Loading";
+import Button from "components/porter/Button";
+import Container from "components/porter/Container";
+import Checkbox from "components/porter/Checkbox";
+type Props = {
+  appData: any;
+  setAppData: Dispatch<any>;
+  onTabSwitch: () => void;
+};
+
+const BuildSettingsTabStack: React.FC<Props> = ({
+  appData,
+  setAppData,
+  onTabSwitch,
+}) => {
+  const { setCurrentError } = useContext(Context);
+  const [updated, setUpdated] = useState(null);
+  const [branch, setBranch] = useState(appData.app.git_branch);
+  const [showSettings, setShowSettings] = useState(false);
+  const [dockerfilePath, setDockerfilePath] = useState(
+    appData.app.dockerfilePath
+  );
+  const [folderPath, setFolderPath] = useState("./");
+  const defaultActionConfig: ActionConfigType = {
+    git_repo: appData.app.repo_name,
+    image_repo_uri: appData.chart.image_repo_uri,
+    git_branch: appData.app.git_branch,
+    git_repo_id: appData.app.git_repo_id,
+    kind: "github",
+  };
+  const defaultBuildConfig: BuildConfig = {
+    builder: appData.app.builder,
+    buildpacks: appData.app.build_packs?.split(","),
+    config: appData.chart.config,
+  };
+  const [buildConfig, setBuildConfig] = useState<BuildConfig>({
+    ...defaultBuildConfig,
+  });
+  const [redeployOnSave, setRedeployOnSave] = useState(true);
+  const [runningWorkflowURL, setRunningWorkflowURL] = useState("");
+
+  const [actionConfig, setActionConfig] = useState<ActionConfigType>({
+    ...defaultActionConfig,
+  });
+  const [buttonStatus, setButtonStatus] = useState<
+    "loading" | "successful" | string
+  >("");
+  const [imageUrl, setImageUrl] = useState(appData.chart.image_uri);
+  
+  const triggerWorkflow = async () => {
+    try {
+      await api.reRunGHWorkflow(
+        "",
+        {},
+        {
+          project_id: appData.app.project_id,
+          cluster_id: appData.app.cluster_id,
+          git_installation_id: appData.app.git_repo_id,
+          owner: appData.app.repo_name?.split("/")[0],
+          name: appData.app.repo_name?.split("/")[1],
+          branch: branch,
+          filename: "porter_stack_" + appData.chart.name + ".yml",
+        }
+      );
+    } catch (error) {
+      if (!error?.response) {
+        throw error;
+      }
+
+      let tmpError: AxiosError = error;
+
+      /**
+       * @smell
+       * Currently the expanded chart is clearing all the state when a chart update is triggered (saveEnvVariables).
+       * Temporary usage of setCurrentError until a context is applied to keep the state of the ReRunError during re renders.
+       */
+
+      if (tmpError.response.status === 400) {
+        // setReRunError({
+        //   title: "No previous run found",
+        //   description:
+        //     "There are no previous runs for this workflow, please trigger manually a run before changing the build settings.",
+        // });
+        setCurrentError(
+          "There are no previous runs for this workflow. Please manually trigger a run before changing build settings."
+        );
+        return;
+      }
+
+      if (tmpError.response.status === 409) {
+        // setReRunError({
+        //   title: "The workflow is still running",
+        //   description:
+        //     'If you want to make more changes, please choose the option "Save" until the workflow finishes.',
+        // });
+
+        if (typeof tmpError.response.data === "string") {
+          setRunningWorkflowURL(tmpError.response.data);
+        }
+        setCurrentError(
+          'The workflow is still running. You can "Save" the current build settings for the next workflow run and view the current status of the workflow here: ' +
+            tmpError.response.data
+        );
+        return;
+      }
+
+      if (tmpError.response.status === 404) {
+        let description = "No action file matching this deployment was found.";
+        if (typeof tmpError.response.data === "string") {
+          const filename = tmpError.response.data;
+          description = description.concat(
+            `Please check that the file "${filename}" exists in your repository.`
+          );
+        }
+        // setReRunError({
+        //   title: "The action doesn't seem to exist",
+        //   description,
+        // });
+
+        setCurrentError(description);
+        return;
+      }
+      throw error;
+    }
+  };
+  const saveConfig = async () => {
+    console.log(appData);
+    try {
+      await api.updatePorterApp(
+        "<token>",
+        {
+          repo_name: appData.app.repo_name,
+          git_branch: branch,
+          build_context: appData.app.build_context,
+          builder: buildConfig.builder,
+          buildpacks: buildConfig.buildpacks?.join(","),
+          dockerfile: appData.app.dockerfile,
+          image_repo_uri: appData.chart.image_repo_uri,
+        },
+        {
+          project_id: appData.app.project_id,
+          cluster_id: appData.app.cluster_id,
+          name: appData.app.name,
+        }
+      );
+      onTabSwitch();
+    } catch (err) {
+      throw err;
+    }
+  };
+  const handleSave = async () => {
+    setButtonStatus("loading");
+
+    try {
+      console.log(buildConfig.builder);
+
+      await saveConfig();
+      setAppData(appData);
+
+      onTabSwitch();
+      setButtonStatus("success");
+    } catch (error) {
+      setButtonStatus("Something went wrong");
+      console.log(error);
+    }
+  };
+  const handleSaveAndReDeploy = async () => {
+    setButtonStatus("loading");
+
+    try {
+      console.log(buildConfig.builder);
+
+      await saveConfig();
+      setAppData(appData);
+
+      await triggerWorkflow();
+
+      onTabSwitch();
+      setButtonStatus("successful");
+    } catch (error) {
+      setButtonStatus("Something went wrong");
+      console.log(error);
+    }
+  };
+  return (
+    <>
+      <Text size={16}>Build settings</Text>
+      {/* <ActionConfEditorStack
+        actionConfig={actionConfig}
+        setActionConfig={(actionConfig: ActionConfigType) => {
+          setActionConfig((currentActionConfig: ActionConfigType) => ({
+            ...currentActionConfig,
+            ...actionConfig,
+          }));
+          setImageUrl(actionConfig.image_repo_uri);
+        }}
+        setBranch={setBranch}
+        setDockerfilePath={setDockerfilePath}
+        setFolderPath={setFolderPath}
+      /> */}
+      <InputRow
+        disabled={true}
+        label="Git repository"
+        type="text"
+        width="100%"
+        value={actionConfig?.git_repo}
+      />
+      {/* <DarkMatter antiHeight="-1px" /> */}
+      {actionConfig.git_repo && (
+        <>
+          <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={0.3} />
+      {actionConfig.git_repo && branch && (
+        <>
+          <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}
+          />
+        </>
+      )}
+      <StyledAdvancedBuildSettings
+        showSettings={showSettings}
+        isCurrent={true}
+        onClick={() => {
+          setShowSettings(!showSettings);
+        }}
+      >
+        <AdvancedBuildTitle>
+          <i className="material-icons dropdown">arrow_drop_down</i>
+          Configure buildpack settings
+        </AdvancedBuildTitle>
+      </StyledAdvancedBuildSettings>
+      <AnimateHeight height={showSettings ? "auto" : 0} duration={1000}>
+        <StyledSourceBox>
+          <Spacer y={0.5} />
+          {actionConfig && (
+            <BuildpackStack
+              actionConfig={actionConfig}
+              branch={branch}
+              folderPath={folderPath}
+              onChange={(config) => {
+                setBuildConfig(config);
+                setDockerfilePath("");
+              }}
+              hide={!showSettings}
+              currentBuildConfig={buildConfig}
+              setBuildConfig={setBuildConfig}
+            />
+          )}
+          <Spacer y={0.5} />
+        </StyledSourceBox>
+      </AnimateHeight>
+      <Spacer y={1} />
+      <Checkbox
+        checked={redeployOnSave}
+        toggleChecked={() => setRedeployOnSave(!redeployOnSave)}
+      >
+        <Text>Re-run build and deploy on save</Text>
+      </Checkbox>
+      <Spacer y={1} />
+      <Button
+        onClick={() => {
+          if (redeployOnSave) {
+            handleSaveAndReDeploy();
+          } else {
+            handleSave();
+          }
+        }}
+        status={buttonStatus}
+      >
+        Save build settings
+      </Button>
+    </>
+  );
+};
+
+export default BuildSettingsTabStack;
+
+const SourceSettingsContainer = styled.div``;
+
+const DarkMatter = styled.div<{ antiHeight?: string }>`
+  width: 100%;
+  margin-top: ${(props) => props.antiHeight || "-15px"};
+`;
+
+const AdvancedBuildTitle = styled.div`
+  display: flex;
+  align-items: center;
+`;
+
+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;
+  margin-top: 15px;
+  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 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 StyledButtonWrapper = styled.div`
+  display: flex;
+  gap: 10px;
+  align-items: center;
+`;
+
+const StyledButton = styled.button`
+  background: #3a48ca;
+  border: 1px solid #494b4f;
+  color: #ffffffff;
+  cursor: pointer;
+  font-size: 13px;
+  padding: 8px 12px;
+  position: relative;
+  border-radius: 5px;
+  margin-bottom: 35px;
+  position: relative;
+  text-align: center;
+  transition: border 0.3s, color 0.3s;
+
+  &:hover {
+    border: 1px solid #7a7b80;
+    color: white;
+  }
+
+  &::after {
+    content: attr(data-description);
+    background-color: #333;
+    border-radius: 4px;
+    bottom: calc(100% + 8px);
+    color: #fff;
+    font-size: 12px;
+    opacity: 0;
+    padding: 8px;
+    position: absolute;
+    left: 0;
+    top: 100%;
+    transform: translateY(0);
+    white-space: nowrap;
+    pointer-events: none;
+  }
+
+  &:hover::after {
+    opacity: 1;
+    bottom: auto;
+    top: 120%;
+  }
+`;
+
+const StyledLoadingDial = styled(Loading)`
+  position: absolute;
+  right: -45px;
+  top: 50%;
+  transform: translateY(-50%);
+`;

+ 9 - 0
dashboard/src/main/home/app-dashboard/expanded-app/DisabledNamespaces.ts

@@ -0,0 +1,9 @@
+export const DisabledNamespacesForIncidents = [
+  "cert-manager",
+  "ingress-nginx",
+  "kube-node-lease",
+  "kube-public",
+  "kube-system",
+  "monitoring",
+  "porter-agent-system",
+];

+ 56 - 0
dashboard/src/main/home/app-dashboard/expanded-app/EnvVariablesTab.tsx

@@ -0,0 +1,56 @@
+import Button from "components/porter/Button";
+import Spacer from "components/porter/Spacer";
+import EnvGroupArray from "main/home/cluster-dashboard/env-groups/EnvGroupArray";
+import React, { useEffect, useState } from "react";
+import styled from "styled-components";
+import Text from "components/porter/Text";
+import Error from "components/porter/Error";
+
+interface EnvVariablesTabProps {
+  envVars: any;
+  setEnvVars: (x: any) => void;
+  status: React.ReactNode;
+  updatePorterApp: () => void;
+  clearStatus: () => void;
+}
+
+export const EnvVariablesTab: React.FC<EnvVariablesTabProps> = ({
+  envVars,
+  setEnvVars,
+  status,
+  updatePorterApp,
+  clearStatus,
+}) => {
+  useEffect(() => {
+    setEnvVars(envVars);
+  }, [envVars]);
+  return (
+    <>
+      <Text size={16}>Environment variables</Text>
+      <Spacer y={0.5} />
+      <Text color="helper">Shared among all services.</Text>
+      <EnvGroupArray
+        key={envVars.length}
+        values={envVars}
+        setValues={(x: any) => {
+          if (status !== "") {
+            clearStatus();
+          }
+          setEnvVars(x)
+        }}
+        fileUpload={true}
+      />
+      <Spacer y={0.5} />
+      <Button
+        onClick={() => {
+          updatePorterApp();
+        }}
+        status={status}
+        loadingText={"Updating..."}
+      >
+        Update app
+      </Button>
+      <Spacer y={0.5} />
+    </>
+  );
+};

+ 637 - 0
dashboard/src/main/home/app-dashboard/expanded-app/EventList.tsx

@@ -0,0 +1,637 @@
+import React, { useState, useEffect, useContext } from "react";
+import { CellProps } from "react-table";
+
+import styled from "styled-components";
+import Table from "components/Table";
+import Loading from "components/Loading";
+import danger from "assets/danger.svg";
+import rocket from "assets/rocket.png";
+import document from "assets/document.svg";
+import info from "assets/info-outlined.svg";
+import status from "assets/info-circle.svg";
+import { readableDate, relativeDate } from "shared/string_utils";
+import TitleSection from "components/TitleSection";
+import api from "shared/api";
+import Modal from "main/home/modals/Modal";
+import time from "assets/time.svg";
+import { Direction, Log, parseLogs } from "./useAgentLogs";
+import { Context } from "shared/Context";
+import dayjs from "dayjs";
+import Anser from "anser";
+
+type Props = {
+  namespace: string;
+  filters: any;
+};
+
+interface ExpandedIncidentLogsProps {
+  logs: Log[];
+}
+
+const ExpandedIncidentLogs = ({ logs }: ExpandedIncidentLogsProps) => {
+  if (!logs.length) {
+    return (
+      <LogsLoadWrapper>
+        <Loading />
+      </LogsLoadWrapper>
+    );
+  }
+
+  return (
+    <LogsSectionWrapper>
+      <StyledLogsSection>
+        {logs?.map((log, i) => {
+          return (
+            <LogSpan key={[log.lineNumber, i].join(".")}>
+              <span className="line-number">{log.lineNumber}.</span>
+              <span className="line-timestamp">
+                {dayjs(log.timestamp).format("MMM D, YYYY HH:mm:ss")}
+              </span>
+              <LogOuter key={[log.lineNumber, i].join(".")}>
+                {log.line?.map((ansi, j) => {
+                  if (ansi.clearLine) {
+                    return null;
+                  }
+
+                  return (
+                    <LogInnerSpan
+                      key={[log.lineNumber, i, j].join(".")}
+                      ansi={ansi}
+                    >
+                      {ansi.content.replace(/ /g, "\u00a0")}
+                    </LogInnerSpan>
+                  );
+                })}
+              </LogOuter>
+            </LogSpan>
+          );
+        })}
+      </StyledLogsSection>
+    </LogsSectionWrapper>
+  );
+};
+
+const EventList: React.FC<Props> = ({ filters, namespace }) => {
+  const { currentProject, currentCluster } = useContext(Context);
+  const [events, setEvents] = useState([]);
+  const [logs, setLogs] = useState<Log[]>([]);
+  const [expandedEvent, setExpandedEvent] = useState(null);
+  const [expandedIncidentEvents, setExpandedIncidentEvents] = useState(null);
+  const [isLoading, setIsLoading] = useState(true);
+  const [refresh, setRefresh] = useState(true);
+
+  useEffect(() => {
+    if (!refresh) {
+      return;
+    }
+
+    if (filters.job_name) {
+      api
+        .listPorterJobEvents("<token>", filters, {
+          project_id: currentProject.id,
+          cluster_id: currentCluster.id,
+        })
+        .then((res) => {
+          setEvents(res.data.events);
+          setIsLoading(false);
+          setRefresh(false);
+        });
+    } else {
+      api
+        .listPorterEvents("<token>", filters, {
+          project_id: currentProject.id,
+          cluster_id: currentCluster.id,
+        })
+        .then((res) => {
+          setEvents(res.data.events);
+          setIsLoading(false);
+          setRefresh(false);
+        });
+    }
+  }, [refresh]);
+
+  useEffect(() => {
+    if (!expandedEvent) {
+      return;
+    }
+
+    api
+      .getIncidentEvents(
+        "<token>",
+        {
+          incident_id: expandedEvent.id,
+        },
+        {
+          project_id: currentProject.id,
+          cluster_id: currentCluster.id,
+        }
+      )
+      .then((res) => {
+        if (!expandedEvent.should_view_logs) {
+          setExpandedIncidentEvents(res.data.events);
+          return null;
+        }
+
+        const events = res.data?.events ?? [];
+
+        api
+          .getLogs(
+            "<token>",
+            {
+              pod_selector: events[0]?.pod_name,
+              namespace,
+              revision: events[0]?.revision,
+              start_range: dayjs(events[0]?.updated_at)
+                .subtract(14, "day")
+                .toISOString(),
+              end_range: dayjs(events[0]?.updated_at).toISOString(),
+              limit: 100,
+              direction: Direction.backward,
+              search_param: "",
+            },
+            {
+              cluster_id: currentCluster.id,
+              project_id: currentProject.id,
+            }
+          )
+          .then((res) => {
+            const logs = parseLogs(
+              res.data.logs
+                ?.filter(Boolean)
+                .map((logLine: any) => logLine.line)
+                .reverse()
+            );
+            setLogs(logs);
+          });
+
+        setExpandedIncidentEvents(res.data.events);
+      });
+  }, [expandedEvent]);
+
+  const renderExpandedEventMessage = () => {
+    if (!expandedIncidentEvents) {
+      return <Loading />;
+    }
+
+    return (
+      <>
+        <Message>
+          <img src={document} />
+          {expandedIncidentEvents[0].detail}
+        </Message>
+        {expandedEvent.should_view_logs ? (
+          <ExpandedIncidentLogs logs={logs} />
+        ) : null}
+      </>
+    );
+  };
+
+  const renderIncidentSummaryCell = (incident: any) => {
+    return (
+      <NameWrapper>
+        <AlertIcon src={danger} />
+        {incident.short_summary}
+        {incident.severity === "normal" ? (
+          <></>
+        ) : (
+          <Status color="#cc3d42">Critical</Status>
+        )}
+      </NameWrapper>
+    );
+  };
+
+  const renderDeploymentFinishedCell = (release: any) => {
+    return (
+      <NameWrapper>
+        <AlertIcon src={rocket} />
+        Revision {release.revision} was successfully deployed
+      </NameWrapper>
+    );
+  };
+
+  const renderJobStartedCell = (timestamp: any) => {
+    return (
+      <NameWrapper>
+        <AlertIcon src={time} />
+        The job started at {readableDate(timestamp)}
+      </NameWrapper>
+    );
+  };
+
+  const renderJobFinishedCell = (timestamp: any) => {
+    return (
+      <NameWrapper>
+        <AlertIcon src={time} />
+        The job finished at {readableDate(timestamp)}
+      </NameWrapper>
+    );
+  };
+
+  const columns = React.useMemo(
+    () => [
+      {
+        Header: "Monitors",
+        columns: [
+          {
+            Header: "Description",
+            accessor: "type",
+            width: 500,
+            Cell: ({ row }: CellProps<any>) => {
+              if (row.original.type == "incident") {
+                return renderIncidentSummaryCell(row.original.data);
+              } else if (row.original.type == "deployment_finished") {
+                return renderDeploymentFinishedCell(row.original.data);
+              } else if (row.original.type == "job_started") {
+                return renderJobStartedCell(row.original.timestamp);
+              } else if (row.original.type == "job_finished") {
+                return renderJobFinishedCell(row.original.timestamp);
+              }
+
+              return null;
+            },
+          },
+          {
+            Header: "Last seen",
+            accessor: "timestamp",
+            width: 140,
+            Cell: ({ row }: CellProps<any>) => {
+              return <Flex>{relativeDate(row.original.timestamp)}</Flex>;
+            },
+          },
+          {
+            id: "details",
+            accessor: "",
+            width: 20,
+            Cell: ({ row }: CellProps<any>) => {
+              if (row.original.type == "incident") {
+                return (
+                  <TableButton
+                    onClick={() => {
+                      setExpandedEvent(row.original.data);
+                    }}
+                  >
+                    <Icon src={info} />
+                    Details
+                  </TableButton>
+                );
+              }
+
+              return null;
+            },
+          },
+        ],
+      },
+    ],
+    []
+  );
+
+  return (
+    <>
+      {expandedEvent && (
+        <Modal
+          onRequestClose={() => {
+            setExpandedEvent(null);
+            setLogs([]);
+          }}
+          height="auto"
+        >
+          <TitleSection icon={danger}>
+            <Text>{expandedEvent.release_name}</Text>
+          </TitleSection>
+          <InfoRow>
+            <InfoTab>
+              <img src={time} /> <Bold>Last updated:</Bold>
+              {readableDate(expandedEvent.updated_at)}
+            </InfoTab>
+            <InfoTab>
+              <img src={info} /> <Bold>Status:</Bold>
+              <Capitalize>{expandedEvent.status}</Capitalize>
+            </InfoTab>
+            <InfoTab>
+              <img src={status} /> <Bold>Priority:</Bold>{" "}
+              <Capitalize>{expandedEvent.severity}</Capitalize>
+            </InfoTab>
+          </InfoRow>
+          {expandedEvent?.porter_doc_link && (
+            <DocsLink target="_blank" href={expandedEvent?.porter_doc_link}>
+              View troubleshooting steps
+              <i className="material-icons">open_in_new</i>{" "}
+            </DocsLink>
+          )}
+          {renderExpandedEventMessage()}
+        </Modal>
+      )}
+      {isLoading ? (
+        <LoadWrapper>
+          <Loading />
+        </LoadWrapper>
+      ) : (
+        <TableWrapper>
+          <Table
+            columns={columns}
+            data={events}
+            placeholder="No events found."
+          />
+          <FlexRow>
+            <Flex>
+              <Button
+                onClick={() => {
+                  setIsLoading(true);
+                  setRefresh(true);
+                }}
+              >
+                <i className="material-icons">autorenew</i>
+                Refresh
+              </Button>
+            </Flex>
+          </FlexRow>
+        </TableWrapper>
+      )}
+    </>
+  );
+};
+
+export default EventList;
+
+const LogsLoadWrapper = styled.div`
+  height: 50px;
+`;
+
+const Message = styled.div`
+  padding: 20px;
+  background: #26292e;
+  border-radius: 5px;
+  line-height: 1.5em;
+  border: 1px solid #aaaabb33;
+  font-size: 13px;
+  display: flex;
+  align-items: center;
+  > img {
+    width: 13px;
+    margin-right: 20px;
+  }
+`;
+
+const Capitalize = styled.div`
+  text-transform: capitalize;
+`;
+
+const Bold = styled.div`
+  font-weight: 500;
+  margin-right: 5px;
+`;
+
+const InfoTab = styled.div`
+  display: flex;
+  align-items: center;
+  opacity: 50%;
+  font-size: 13px;
+  margin-right: 15px;
+  justify-content: center;
+
+  > img {
+    width: 13px;
+    margin-right: 7px;
+  }
+`;
+
+const InfoRow = styled.div`
+  display: flex;
+  align-items: center;
+  justify-content: flex-start;
+  margin-bottom: 12px;
+`;
+
+const Text = styled.div`
+  font-weight: 500;
+  font-size: 18px;
+  z-index: 999;
+`;
+
+const Icon = styled.img`
+  width: 16px;
+  margin-right: 6px;
+`;
+
+const TableButton = styled.div<{ width?: string }>`
+  border-radius: 5px;
+  height: 30px;
+  color: white;
+  width: ${(props) => props.width || "85px"};
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  background: #ffffff11;
+  border: 1px solid #aaaabb33;
+  margin-right: -17px;
+  cursor: pointer;
+  :hover {
+    border: 1px solid #7a7b80;
+  }
+`;
+
+const ClusterName = styled.div`
+  overflow: hidden;
+  white-space: nowrap;
+  text-overflow: ellipsis;
+  background: blue;
+  width: 100px;
+`;
+
+const Title = styled.div`
+  font-size: 18px;
+  margin-bottom: 10px;
+  color: #ffffff;
+`;
+
+const Placeholder = styled.div`
+  width: 100%;
+  height: 300px;
+  color: #aaaabb55;
+  display: flex;
+  font-size: 14px;
+  padding-right: 50px;
+  align-items: center;
+  justify-content: center;
+`;
+
+const ClusterIcon = styled.img`
+  width: 14px;
+  margin-right: 9px;
+  opacity: 70%;
+`;
+
+const Flex = styled.div`
+  display: flex;
+  align-items: center;
+`;
+
+const AlertIcon = styled.img`
+  width: 20px;
+  margin-right: 15px;
+  margin-left: 0px;
+`;
+
+const NameWrapper = styled.div`
+  display: flex;
+  align-items: center;
+  color: white;
+`;
+
+const LoadWrapper = styled.div`
+  width: 100%;
+  height: 300px;
+`;
+
+const Status = styled.div<{ color: string }>`
+  padding: 5px 7px;
+  background: ${(props) => props.color};
+  font-size: 12px;
+  border-radius: 3px;
+  word-break: keep-all;
+  display: flex;
+  color: white;
+  margin-right: 50px;
+  align-items: center;
+  margin-left: 15px;
+  justify-content: center;
+  height: 20px;
+`;
+
+const TableWrapper = styled.div`
+  overflow-x: auto;
+  animation: fadeIn 0.3s;
+  animation-timing-function: ease-out;
+  animation-fill-mode: forwards;
+`;
+
+const Button = styled.div`
+  background: #26292e;
+  border-radius: 5px;
+  height: 30px;
+  font-size: 13px;
+  display: flex;
+  cursor: pointer;
+  align-items: center;
+  padding: 10px;
+  padding-left: 8px;
+  > i {
+    font-size: 16px;
+    margin-right: 5px;
+  }
+  border: 1px solid #494b4f;
+  :hover {
+    border: 1px solid #7a7b80;
+  }
+`;
+
+const FlexRow = styled.div`
+  display: flex;
+  align-items: center;
+  justify-content: flex-end;
+  flex-wrap: wrap;
+  margin-top: 20px;
+`;
+
+const DocsLink = styled.a`
+  display: inline-block;
+  color: #8590ff;
+  border-bottom: 1px solid #8590ff;
+  cursor: pointer;
+  user-select: none;
+  padding: 3px 0;
+  margin-bottom: 18px;
+
+  > i {
+    font-size: 12px;
+    margin-left: 5px;
+  }
+`;
+
+const LogsSectionWrapper = styled.div`
+  position: relative;
+`;
+
+const StyledLogsSection = styled.div`
+  margin-top: 20px;
+  width: 100%;
+  display: flex;
+  flex-direction: column;
+  position: relative;
+  font-size: 13px;
+  max-height: 400px;
+  border-radius: 8px;
+  border: 1px solid #ffffff33;
+  border-top: none;
+  background: #101420;
+  animation: floatIn 0.3s;
+  animation-timing-function: ease-out;
+  animation-fill-mode: forwards;
+  overflow-y: auto;
+  overflow-wrap: break-word;
+  position: relative;
+  @keyframes floatIn {
+    from {
+      opacity: 0;
+      transform: translateY(10px);
+    }
+    to {
+      opacity: 1;
+      transform: translateY(0px);
+    }
+  }
+`;
+
+const LogSpan = styled.div`
+  font-family: monospace;
+  user-select: text;
+  display: flex;
+  align-items: flex-end;
+  gap: 8px;
+  width: 100%;
+  & > * {
+    padding-block: 5px;
+  }
+  & > .line-timestamp {
+    height: 100%;
+    color: #949effff;
+    opacity: 0.5;
+    font-family: monospace;
+    min-width: fit-content;
+    padding-inline-end: 5px;
+  }
+  & > .line-number {
+    height: 100%;
+    background: #202538;
+    display: inline-block;
+    text-align: right;
+    min-width: 45px;
+    padding-inline-end: 5px;
+    opacity: 0.3;
+    font-family: monospace;
+  }
+`;
+
+const LogOuter = styled.div`
+  display: inline-block;
+  word-wrap: anywhere;
+  flex-grow: 1;
+  font-family: monospace, sans-serif;
+  font-size: 12px;
+`;
+
+const LogInnerSpan = styled.span`
+  font-family: monospace, sans-serif;
+  font-size: 12px;
+  font-weight: ${(props: { ansi: Anser.AnserJsonEntry }) =>
+    props.ansi?.decoration && props.ansi?.decoration == "bold" ? "700" : "400"};
+  color: ${(props: { ansi: Anser.AnserJsonEntry }) =>
+    props.ansi?.fg ? `rgb(${props.ansi?.fg})` : "white"};
+  background-color: ${(props: { ansi: Anser.AnserJsonEntry }) =>
+    props.ansi?.bg ? `rgb(${props.ansi?.bg})` : "transparent"};
+`;
+
+export const ViewLogsWrapper = styled.div`
+  margin-bottom: -15px;
+  margin-top: 15px;
+`;

+ 224 - 0
dashboard/src/main/home/app-dashboard/expanded-app/EventsTab.tsx

@@ -0,0 +1,224 @@
+import React, { useEffect, useContext, useState } from "react";
+import api from "shared/api";
+import styled from "styled-components";
+import EventList from "./EventList";
+import Loading from "components/Loading";
+import { Context } from "shared/Context";
+
+type Props = {
+  currentChart: any;
+};
+
+const EventsTab: React.FC<Props> = ({ currentChart }) => {
+  const [hasPorterAgent, setHasPorterAgent] = useState(true);
+  const [isPorterAgentInstalling, setIsPorterAgentInstalling] = useState(false);
+  const { currentProject, currentCluster } = useContext(Context);
+  const [isLoading, setIsLoading] = useState(true);
+
+  useEffect(() => {
+    // determine if the agent is installed properly - if not, start by render upgrade screen
+    checkForAgent();
+  }, []);
+
+  useEffect(() => {
+    if (!isPorterAgentInstalling) {
+      return;
+    }
+
+    const checkForAgentInterval = setInterval(checkForAgent, 3000);
+
+    return () => clearInterval(checkForAgentInterval);
+  }, [isPorterAgentInstalling]);
+
+  const checkForAgent = () => {
+    const project_id = currentProject?.id;
+    const cluster_id = currentCluster?.id;
+
+    api
+      .detectPorterAgent("<token>", {}, { project_id, cluster_id })
+      .then((res) => {
+        if (res.data?.version != "v3") {
+          setHasPorterAgent(false);
+        } else {
+          // next, check whether events can be queried - if they can, we're good to go
+          let filters: any = getFilters();
+
+          let apiQuery = api.listPorterEvents;
+
+          if (filters.job_name) {
+            apiQuery = api.listPorterJobEvents;
+          }
+
+          apiQuery("<token>", filters, {
+            project_id: currentProject.id,
+            cluster_id: currentCluster.id,
+          })
+            .then((res) => {
+              setHasPorterAgent(true);
+              setIsPorterAgentInstalling(false);
+            })
+            .catch((err) => {
+              // do nothing - this is expected while installing
+            });
+        }
+
+        setIsLoading(false);
+      })
+      .catch((err) => {
+        if (err.response?.status === 404) {
+          setHasPorterAgent(false);
+          setIsLoading(false);
+        }
+      });
+  };
+
+  const installAgent = async () => {
+    const project_id = currentProject?.id;
+    const cluster_id = currentCluster?.id;
+
+    setIsPorterAgentInstalling(true);
+
+    api
+      .installPorterAgent("<token>", {}, { project_id, cluster_id })
+      .then()
+      .catch((err) => {
+        setIsPorterAgentInstalling(false);
+        console.log(err);
+      });
+  };
+
+  const triggerInstall = () => {
+    installAgent();
+  };
+
+  const getFilters = () => {
+    return {
+      release_name: currentChart.name,
+      release_namespace: currentChart.namespace,
+    };
+  };
+
+  if (isPorterAgentInstalling) {
+    return (
+      <Placeholder>
+        <Header>Installing agent...</Header>
+      </Placeholder>
+    );
+  }
+
+  if (isLoading) {
+    return (
+      <Placeholder>
+        <Loading />
+      </Placeholder>
+    );
+  }
+
+  if (!hasPorterAgent) {
+    return (
+      <Placeholder>
+        <div>
+          <Header>We couldn't detect the Porter agent on your cluster</Header>
+          In order to use the events tab, you need to install the Porter agent.
+          <InstallPorterAgentButton onClick={() => triggerInstall()}>
+            <i className="material-icons">add</i> Install Porter agent
+          </InstallPorterAgentButton>
+        </div>
+      </Placeholder>
+    );
+  }
+
+  return (
+    <EventsPageWrapper>
+      <EventList namespace={currentChart.namespace} filters={getFilters()} />
+    </EventsPageWrapper>
+  );
+};
+
+export default EventsTab;
+
+const EventsPageWrapper = styled.div`
+  font-size: 13px;
+  border-radius: 8px;
+  animation: floatIn 0.3s;
+  animation-timing-function: ease-out;
+  animation-fill-mode: forwards;
+  @keyframes floatIn {
+    from {
+      opacity: 0;
+      transform: translateY(10px);
+    }
+    to {
+      opacity: 1;
+      transform: translateY(0px);
+    }
+  }
+`;
+
+const InstallPorterAgentButton = styled.button`
+  display: flex;
+  flex-direction: row;
+  align-items: center;
+  justify-content: space-between;
+  font-size: 13px;
+  cursor: pointer;
+  font-family: "Work Sans", sans-serif;
+  border: none;
+  border-radius: 5px;
+  color: white;
+  height: 35px;
+  padding: 0px 8px;
+  padding-bottom: 1px;
+  margin-top: 20px;
+  font-weight: 500;
+  padding-right: 15px;
+  overflow: hidden;
+  white-space: nowrap;
+  text-overflow: ellipsis;
+  cursor: ${(props: { disabled?: boolean }) =>
+    props.disabled ? "not-allowed" : "pointer"};
+  background: ${(props: { disabled?: boolean }) =>
+    props.disabled ? "#aaaabbee" : "#5561C0"};
+  :hover {
+    filter: ${(props) => (!props.disabled ? "brightness(120%)" : "")};
+  }
+  > i {
+    color: white;
+    width: 18px;
+    height: 18px;
+    font-weight: 600;
+    font-size: 12px;
+    border-radius: 20px;
+    display: flex;
+    align-items: center;
+    margin-right: 5px;
+    justify-content: center;
+  }
+`;
+
+const Placeholder = styled.div`
+  padding: 30px;
+  padding-bottom: 40px;
+  font-size: 13px;
+  color: #ffffff44;
+  min-height: 400px;
+  height: 50vh;
+  background: #ffffff08;
+  border-radius: 8px;
+  width: 100%;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+
+  > i {
+    font-size: 18px;
+    margin-right: 8px;
+  }
+`;
+
+const Header = styled.div`
+  font-weight: 500;
+  color: #aaaabb;
+  font-size: 16px;
+  margin-bottom: 15px;
+`;

+ 805 - 66
dashboard/src/main/home/app-dashboard/expanded-app/ExpandedApp.tsx

@@ -1,39 +1,102 @@
-import React, { useEffect, useState, useContext } from "react";
+import React, { useEffect, useState, useContext, useCallback } from "react";
 import { RouteComponentProps, withRouter } from "react-router";
 import styled from "styled-components";
+import yaml from "js-yaml";
+import { z } from "zod";
+
+import notFound from "assets/not-found.png";
+import web from "assets/web.png";
+import box from "assets/box.png";
+import github from "assets/github.png";
+import pr_icon from "assets/pull_request_icon.svg";
+import loadingImg from "assets/loading.gif";
+import refresh from "assets/refresh.png";
 
 import api from "shared/api";
 import { Context } from "shared/Context";
+import useAuth from "shared/auth/useAuth";
+import Error from "components/porter/Error";
 
-import notFound from "assets/not-found.png";
-
-import Fieldset from "components/porter/Fieldset";
+import Banner from "components/porter/Banner";
 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";
+import { ChartType, ResourceType } from "shared/types";
+import RevisionSection from "main/home/cluster-dashboard/expanded-chart/RevisionSection";
+import BuildSettingsTabStack from "./BuildSettingsTabStack";
+import Button from "components/porter/Button";
+import Services from "../new-app-flow/Services";
+import { Service } from "../new-app-flow/serviceTypes";
+import ConfirmOverlay from "components/porter/ConfirmOverlay";
+import Fieldset from "components/porter/Fieldset";
+import { PorterJson, createFinalPorterYaml } from "../new-app-flow/schema";
+import EnvGroupArray, {
+  KeyValueType,
+} from "main/home/cluster-dashboard/env-groups/EnvGroupArray";
+import { PorterYamlSchema } from "../new-app-flow/schema";
+import { EnvVariablesTab } from "./EnvVariablesTab";
+import GHABanner from "./GHABanner";
+import LogSection from "./LogSection";
+import EventsTab from "./EventsTab";
 
-type Props = RouteComponentProps & {
-};
+type Props = RouteComponentProps & {};
+
+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 ExpandedApp: React.FC<Props> = ({
-  ...props
-}) => {
-  const { currentCluster, currentProject } = useContext(Context);
+const ExpandedApp: React.FC<Props> = ({ ...props }) => {
+  const { currentCluster, currentProject, setCurrentError } = useContext(
+    Context
+  );
   const [isLoading, setIsLoading] = useState(true);
+  const [deleting, setDeleting] = useState(false);
   const [appData, setAppData] = useState(null);
-  const [tab, setTab] = useState("events");
-  const [isExpanded, setIsExpanded] = useState(false);
+  const [workflowCheckPassed, setWorkflowCheckPassed] = useState<boolean>(
+    false
+  );
+  const [hasBuiltImage, setHasBuiltImage] = useState<boolean>(false);
+
+  const [error, setError] = useState(null);
+  const [forceRefreshRevisions, setForceRefreshRevisions] = useState<boolean>(
+    false
+  );
+  const [isLoadingChartData, setIsLoadingChartData] = useState<boolean>(true);
+  const [imageIsPlaceholder, setImageIsPlaceholer] = useState<boolean>(false);
+
+  const [tab, setTab] = useState("overview");
+  const [saveValuesStatus, setSaveValueStatus] = useState<string>(null);
+  const [loading, setLoading] = useState<boolean>(false);
+  const [components, setComponents] = useState<ResourceType[]>([]);
+
+  const [showRevisions, setShowRevisions] = useState<boolean>(false);
+  const [newestImage, setNewestImage] = useState<string>(null);
+  const [showDeleteOverlay, setShowDeleteOverlay] = useState<boolean>(false);
+  const [porterJson, setPorterJson] = useState<
+    z.infer<typeof PorterYamlSchema> | undefined
+  >(undefined);
+
+  const [services, setServices] = useState<Service[]>([]);
+  const [envVars, setEnvVars] = useState<KeyValueType[]>([]);
+  const [buttonStatus, setButtonStatus] = useState<React.ReactNode>("");
+  const [subdomain, setSubdomain] = useState<string>("");
 
   const getPorterApp = async () => {
-    setIsLoading(true);
+    // setIsLoading(true);
     const { appName } = props.match.params as any;
     try {
-      const res = await api.getPorterApp(
+      if (!currentCluster || !currentProject) {
+        return;
+      }
+      const resPorterApp = await api.getPorterApp(
         "<token>",
         {},
         {
@@ -42,101 +105,746 @@ const ExpandedApp: React.FC<Props> = ({
           name: appName,
         }
       );
-      setAppData(res.data);
-      setIsLoading(false);
+      const resChartData = await api.getChart(
+        "<token>",
+        {},
+        {
+          id: currentProject.id,
+          namespace: `porter-stack-${appName}`,
+          cluster_id: currentCluster.id,
+          name: appName,
+          revision: 0,
+        }
+      );
+
+      // Only check GHA status if no built image is set
+      const hasBuiltImage = !!resChartData.data.config?.global?.image
+        ?.repository;
+      if (hasBuiltImage || !resPorterApp.data.repo_name) {
+        setWorkflowCheckPassed(true);
+        setHasBuiltImage(true);
+      } else {
+        try {
+          const resBranchContents = await api.getBranchContents(
+            "<token>",
+            {
+              dir: `./.github/workflows/porter_stack_${resPorterApp.data.name}.yml`,
+            },
+            {
+              project_id: currentProject.id,
+              git_repo_id: resPorterApp.data.git_repo_id,
+              kind: "github",
+              owner: resPorterApp.data.repo_name.split("/")[0],
+              name: resPorterApp.data.repo_name.split("/")[1],
+              branch: resPorterApp.data.git_branch,
+            }
+          );
+          setWorkflowCheckPassed(true);
+        } catch (err) {
+          // Handle unmerged PR
+          if (err.response?.status === 404) {
+            try {
+              // Check for user-copied porter.yml as fallback
+              const resPorterYml = await api.getBranchContents(
+                "<token>",
+                { dir: `./.github/workflows/porter.yml` },
+                {
+                  project_id: currentProject.id,
+                  git_repo_id: resPorterApp.data.git_repo_id,
+                  kind: "github",
+                  owner: resPorterApp.data.repo_name.split("/")[0],
+                  name: resPorterApp.data.repo_name.split("/")[1],
+                  branch: resPorterApp.data.git_branch,
+                }
+              );
+              setWorkflowCheckPassed(true);
+            } catch (err) {
+              setWorkflowCheckPassed(false);
+            }
+          }
+        }
+      }
+
+      const newAppData = {
+        app: resPorterApp?.data,
+        chart: resChartData?.data,
+      };
+      const porterJson = await fetchPorterYamlContent(
+        "porter.yaml",
+        newAppData
+      );
+      setPorterJson(porterJson);
+      setAppData(newAppData);
+      updateServicesAndEnvVariables(resChartData?.data, porterJson);
     } catch (err) {
+      setError(err);
+      console.log(err);
+    } finally {
       setIsLoading(false);
     }
-  }
+  };
+
+  const deletePorterApp = async () => {
+    setShowDeleteOverlay(false);
+    setDeleting(true);
+    const { appName } = props.match.params as any;
+    try {
+      const res = await api.deletePorterApp(
+        "<token>",
+        {},
+        {
+          cluster_id: currentCluster.id,
+          project_id: currentProject.id,
+          name: appName,
+        }
+      );
+      const nsRes = await api.deleteNamespace(
+        "<token>",
+        {},
+        {
+          cluster_id: currentCluster.id,
+          id: currentProject.id,
+          namespace: `porter-stack-${appName}`,
+        }
+      );
+      props.history.push("/apps");
+    } catch (err) {
+      setError(err);
+      setDeleting(false);
+    }
+  };
+
+  const updatePorterApp = async () => {
+    try {
+      setButtonStatus("loading");
+      if (
+        appData != null &&
+        currentCluster != null &&
+        currentProject != null &&
+        appData.app != null
+      ) {
+        const finalPorterYaml = createFinalPorterYaml(
+          services,
+          envVars,
+          porterJson,
+          appData.app.name,
+          currentProject.id,
+          currentCluster.id
+        );
+        const yamlString = yaml.dump(finalPorterYaml);
+        const base64Encoded = btoa(yamlString);
+        await api.updatePorterStack(
+          "<token>",
+          {
+            stack_name: appData.app.name,
+            porter_yaml: base64Encoded,
+          },
+          {
+            cluster_id: currentCluster.id,
+            project_id: currentProject.id,
+            stack_name: appData.app.name,
+          }
+        );
+        setButtonStatus("success")
+      } else {
+        setButtonStatus(<Error message="Unable to update app" />);
+      }
+    } catch (err) {
+      // TODO: better error handling
+      console.log(err);
+      const errMessage =
+        err?.response?.data?.error ??
+        err?.toString() ??
+        "An error occurred while deploying your app. Please try again.";
+      setButtonStatus(<Error message={errMessage} />);
+    }
+  };
+
+  const fetchPorterYamlContent = async (
+    porterYaml: string,
+    appData: any
+  ): Promise<PorterJson | undefined> => {
+    try {
+      const res = await api.getPorterYamlContents(
+        "<token>",
+        {
+          path: porterYaml,
+        },
+        {
+          project_id: appData.app.project_id,
+          git_repo_id: appData.app.git_repo_id,
+          owner: appData.app.repo_name?.split("/")[0],
+          name: appData.app.repo_name?.split("/")[1],
+          kind: "github",
+          branch: appData.app.git_branch,
+        }
+      );
+      if (res.data == null || res.data == "") {
+        return undefined;
+      }
+      const parsedYaml = yaml.load(atob(res.data));
+      const parsedData = PorterYamlSchema.parse(parsedYaml);
+      const porterYamlToJson = parsedData as PorterJson;
+      return porterYamlToJson;
+    } catch (err) {
+      console.log(err);
+    }
+  };
+
+  const renderIcon = (b: string, size?: string) => {
+    var src = box;
+    if (b) {
+      const bp = b.split(",")[0]?.split("/")[1];
+      switch (bp) {
+        case "ruby":
+          src = icons[0];
+          break;
+        case "nodejs":
+          src = icons[1];
+          break;
+        case "python":
+          src = icons[2];
+          break;
+        case "go":
+          src = icons[3];
+          break;
+        default:
+          break;
+      }
+    }
+    return <Icon src={src} />;
+  };
+
+  const updateServicesAndEnvVariables = async (
+    currentChart?: ChartType,
+    porterJson?: PorterJson
+  ) => {
+    const helmValues = currentChart?.config;
+    const defaultValues = (currentChart?.chart as any)?.values;
+    if (
+      (defaultValues && Object.keys(defaultValues).length > 0) ||
+      (helmValues && Object.keys(helmValues).length > 0)
+    ) {
+      const svcs = Service.deserialize(helmValues, defaultValues);
+      setServices(svcs);
+      if (helmValues && Object.keys(helmValues).length > 0) {
+        const envs = Service.retrieveEnvFromHelmValues(helmValues);
+        setEnvVars(envs);
+        const subdomain = Service.retrieveSubdomainFromHelmValues(
+          svcs,
+          helmValues
+        );
+        setSubdomain(subdomain);
+      }
+    }
+  };
+
+  const updateComponents = async (currentChart: ChartType) => {
+    setLoading(true);
+    try {
+      const res = await api.getChartComponents(
+        "<token>",
+        {},
+        {
+          id: currentProject.id,
+          name: currentChart.name,
+          namespace: currentChart.namespace,
+          cluster_id: currentCluster.id,
+          revision: currentChart.version,
+        }
+      );
+      setComponents(res.data.Objects);
+      updateServicesAndEnvVariables(currentChart, porterJson);
+      setLoading(false);
+    } catch (error) {
+      console.log(error);
+      setLoading(false);
+    }
+  };
+
+  const getChartData = async (chart: ChartType) => {
+    setIsLoadingChartData(true);
+    const res = await api.getChart(
+      "<token>",
+      {},
+      {
+        name: chart.name,
+        namespace: chart.namespace,
+        cluster_id: currentCluster.id,
+        revision: chart.version,
+        id: currentProject.id,
+      }
+    );
+    const image = res.data?.config?.image?.repository;
+    const tag = res.data?.config?.image?.tag?.toString();
+    const newNewestImage = tag ? image + ":" + tag : image;
+    let imageIsPlaceholder = false;
+    if (
+      (image === "porterdev/hello-porter" ||
+        image === "public.ecr.aws/o1j4x7p4/hello-porter") &&
+      !newestImage
+    ) {
+      imageIsPlaceholder = true;
+    }
+    setImageIsPlaceholer(imageIsPlaceholder);
+    setNewestImage(newNewestImage);
+
+    const updatedChart = res.data;
+
+    if (appData != null && updatedChart != null) {
+      setAppData({ ...appData, chart: updatedChart });
+    }
+
+    updateComponents(updatedChart).finally(() => setIsLoadingChartData(false));
+  };
+
+  const setRevision = (chart: ChartType, isCurrent?: boolean) => {
+    // // if we've set the revision, we also override the revision in log data
+    // let newLogData = logData;
+
+    // newLogData.revision = `${chart.version}`;
+
+    // setLogData(newLogData);
+
+    // setIsPreview(!isCurrent);
+    getChartData(chart);
+  };
+
+  const appUpgradeVersion = useCallback(
+    async (version: string, cb: () => void) => {
+      // convert current values to yaml
+      const values = appData.chart.config;
+
+      const valuesYaml = yaml.dump({
+        ...values,
+      });
+
+      setSaveValueStatus("loading");
+      getChartData(appData.chart);
+
+      try {
+        await api.upgradeChartValues(
+          "<token>",
+          {
+            values: valuesYaml,
+            version: version,
+            latest_revision: appData.chart.version,
+          },
+          {
+            id: currentProject.id,
+            namespace: appData.chart.namespace,
+            name: appData.chart.name,
+            cluster_id: currentCluster.id,
+          }
+        );
+        setSaveValueStatus("successful");
+        setForceRefreshRevisions(true);
+
+        window.analytics?.track("Chart Upgraded", {
+          chart: appData.chart.name,
+          values: valuesYaml,
+        });
+
+        cb && cb();
+      } catch (err) {
+        const parsedErr = err?.response?.data?.error;
+
+        if (parsedErr) {
+          err = parsedErr;
+        }
+
+        setSaveValueStatus(err);
+        setCurrentError(parsedErr);
+
+        window.analytics?.track("Failed to Upgrade Chart", {
+          chart: appData.chart.name,
+          values: valuesYaml,
+          error: err,
+        });
+      }
+    },
+    [appData?.chart]
+  );
 
   useEffect(() => {
-    if (currentCluster) {
+    const { appName } = props.match.params as any;
+    if (currentCluster && appName && currentProject) {
       getPorterApp();
     }
   }, [currentCluster]);
 
+  const getReadableDate = (s: string) => {
+    const ts = new Date(s);
+    const date = ts.toLocaleDateString();
+    const time = ts.toLocaleTimeString([], {
+      hour: "numeric",
+      minute: "2-digit",
+    });
+    return `${time} on ${date}`;
+  };
   const renderTabContents = () => {
     switch (tab) {
       case "overview":
         return (
-          <div>TODO: service list</div>
+          <>
+            {!isLoading && services.length === 0 && (
+              <>
+                <Fieldset>
+                  <Container row>
+                    <PlaceholderIcon src={notFound} />
+                    <Text color="helper">No services were found.</Text>
+                  </Container>
+                </Fieldset>
+              </>
+            )}
+            <Services 
+              setServices={(x) => {
+                if (buttonStatus !== "") {
+                  setButtonStatus("");
+                }
+                setServices(x);
+              }} 
+              services={services} />
+            <Spacer y={1} />
+            <Button
+              onClick={updatePorterApp}
+              status={buttonStatus}
+              loadingText={"Updating..."}
+              disabled={services.length === 0}
+            >
+              Update app
+            </Button>
+          </>
         );
       case "build-settings":
         return (
-          <div>TODO: build settings</div>
+          <BuildSettingsTabStack
+            appData={appData}
+            setAppData={setAppData}
+            onTabSwitch={getPorterApp}
+          />
         );
       case "settings":
         return (
-          <div>TODO: stack deletion</div>
-        )
-      default:
+          <>
+            <Text size={16}>Delete "{appData.app.name}"</Text>
+            <Spacer y={1} />
+            <Text color="helper">
+              Delete this application and all of its resources.
+            </Text>
+            <Spacer y={1} />
+            <Button
+              onClick={() => {
+                setShowDeleteOverlay(true);
+              }}
+              color="#b91133"
+            >
+              Delete
+            </Button>
+          </>
+        );
+      case "events":
+        return <EventsTab currentChart={appData.chart} />;
+      case "logs":
+        return <LogSection currentChart={appData.chart} />;
+      case "environment-variables":
         return (
-          <div>dream on</div>
-        )
+          <EnvVariablesTab
+            envVars={envVars}
+            setEnvVars={setEnvVars}
+            status={buttonStatus}
+            updatePorterApp={updatePorterApp}
+            clearStatus={() => setButtonStatus("")}
+          />
+        );
+      default:
+        return <div>dream on</div>;
     }
   };
 
   return (
-    <StyledExpandedApp>
-      {isLoading && (
-        <Loading />
-      )}
+    <>
+      {isLoading && <Loading />}
       {!appData && !isLoading && (
         <Placeholder>
           <Container row>
             <PlaceholderIcon src={notFound} />
             <Text color="helper">
-              No application matching "{(props.match.params as any).appName}" was found.
+              No application matching "{(props.match.params as any).appName}"
+              was found.
             </Text>
           </Container>
           <Spacer y={1} />
-          <Link to="/apps">Return to dashboard</Link> 
+          <Link to="/apps">Return to dashboard</Link>
         </Placeholder>
       )}
-      {appData && (
-        <>
+      {appData && appData.app && (
+        <StyledExpandedApp>
           <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>
+            {renderIcon(appData.app?.build_packs)}
+            <Text size={21}>{appData.app.name}</Text>
+            {appData.app.repo_name && (
+              <>
+                <Spacer inline x={1} />
+                <Text size={13} color="helper">
+                  <SmallIcon src={github} />
+                  {appData.app.repo_name}
+                </Text>
+              </>
+            )}
+            {appData.app.git_branch && (
+              <>
+                <Spacer inline x={1} />
+                <TagWrapper>
+                  Branch
+                  <BranchTag>
+                    <BranchIcon src={pr_icon} />
+                    {appData.app.git_branch}
+                  </BranchTag>
+                </TagWrapper>
+              </>
+            )}
+            {!appData.app.repo_name && appData.app.image_repo_uri && (
+              <>
+                <Spacer inline x={1} />
+                <Text size={13} color="helper">
+                  <SmallIcon
+                    height="19px"
+                    src="https://cdn4.iconfinder.com/data/icons/logos-and-brands/512/97_Docker_logo_logos-512.png"
+                  />
+                  {appData.app.image_repo_uri}
+                </Text>
+              </>
+            )}
           </Container>
-          <Spacer y={1} />
-          <Text color="helper">
-            Last updated 2 days ago
+          <Spacer y={0.5} />
+          {subdomain && (
+            <>
+              <Container>
+                <Text>
+                  <a href={subdomain} target="_blank">
+                    {subdomain}
+                  </a>
+                </Text>
+              </Container>
+              <Spacer y={0.5} />
+            </>
+          )}
+          <Text color="#aaaabb66">
+            Last deployed {getReadableDate(appData.chart.info.last_deployed)}
           </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()}
-        </>
+          {deleting ? (
+            <Fieldset>
+              <Text size={16}>
+                <Spinner src={loadingImg} /> Deleting "{appData.app.name}"
+              </Text>
+              <Spacer y={0.5} />
+              <Text color="helper">
+                You will be automatically redirected after deletion is complete.
+              </Text>
+            </Fieldset>
+          ) : (
+            <>
+              {!workflowCheckPassed ? (
+                <GHABanner
+                  repoName={appData.app.repo_name}
+                  branchName={appData.app.git_branch}
+                  pullRequestUrl={appData.app.pull_request_url}
+                  stackName={appData.app.name}
+                  gitRepoId={appData.app.git_repo_id}
+                />
+              ) : !hasBuiltImage ? (
+                <Banner
+                  suffix={
+                    <RefreshButton onClick={() => window.location.reload()}>
+                      <img src={refresh} /> Refresh
+                    </RefreshButton>
+                  }
+                >
+                  Your GitHub repo has not been built yet.
+                  <Spacer inline width="5px" />
+                  <Link
+                    hasunderline
+                    target="_blank"
+                    to={`https://github.com/${appData.app.repo_name}/actions`}
+                  >
+                    Check status
+                  </Link>
+                </Banner>
+              ) : (
+                <>
+                  <DarkMatter />
+                  <RevisionSection
+                    showRevisions={showRevisions}
+                    toggleShowRevisions={() => {
+                      setShowRevisions(!showRevisions);
+                    }}
+                    chart={appData.chart}
+                    refreshChart={() => getChartData(appData.chart)}
+                    setRevision={setRevision}
+                    forceRefreshRevisions={forceRefreshRevisions}
+                    refreshRevisionsOff={() => setForceRefreshRevisions(false)}
+                    shouldUpdate={
+                      appData.chart.latest_version &&
+                      appData.chart.latest_version !==
+                        appData.chart.chart.metadata.version
+                    }
+                    latestVersion={appData.chart.latest_version}
+                    upgradeVersion={appUpgradeVersion}
+                  />
+                  <DarkMatter antiHeight="-18px" />
+                </>
+              )}
+              <Spacer y={1} />
+              <TabSelector
+                options={
+                  appData.app.git_repo_id
+                    ? hasBuiltImage
+                      ? [
+                          { label: "Logs", value: "logs" },
+                          { label: "Overview", value: "overview" },
+                          {
+                            label: "Environment variables",
+                            value: "environment-variables",
+                          },
+                          { label: "Build settings", value: "build-settings" },
+                          { label: "Settings", value: "settings" },
+                        ]
+                      : [
+                          { label: "Overview", value: "overview" },
+                          {
+                            label: "Environment variables",
+                            value: "environment-variables",
+                          },
+                          { label: "Build settings", value: "build-settings" },
+                          { label: "Settings", value: "settings" },
+                        ]
+                    : [
+                        { label: "Logs", value: "logs" },
+                        { label: "Overview", value: "overview" },
+                        {
+                          label: "Environment variables",
+                          value: "environment-variables",
+                        },
+                        { label: "Settings", value: "settings" },
+                      ]
+                }
+                currentTab={tab}
+                setCurrentTab={(tab: string) => {
+                  if (buttonStatus !== "") {
+                    setButtonStatus("");
+                  }
+                  setTab(tab);
+                }}
+              />
+              <Spacer y={1} />
+              {renderTabContents()}
+              <Spacer y={2} />
+            </>
+          )}
+        </StyledExpandedApp>
       )}
-    </StyledExpandedApp>
+      {showDeleteOverlay && (
+        <ConfirmOverlay
+          message={`Are you sure you want to delete "${appData.app.name}"?`}
+          onYes={() => {
+            deletePorterApp();
+          }}
+          onNo={() => {
+            setShowDeleteOverlay(false);
+          }}
+        />
+      )}
+    </>
   );
 };
 
 export default withRouter(ExpandedApp);
 
+const RefreshButton = styled.div`
+  color: #ffffff44;
+  display: flex;
+  align-items: center;
+  cursor: pointer;
+  :hover {
+    color: #ffffff;
+    > img {
+      opacity: 1;
+    }
+  }
+
+  > img {
+    display: flex;
+    align-items: center;
+    justify-content: center;
+    height: 11px;
+    margin-right: 10px;
+    opacity: 0.3;
+  }
+`;
+
+const Spinner = styled.img`
+  width: 15px;
+  height: 15px;
+  margin-right: 12px;
+  margin-bottom: -2px;
+`;
+
+const DarkMatter = styled.div<{ antiHeight?: string }>`
+  width: 100%;
+  margin-top: ${(props) => props.antiHeight || "-20px"};
+`;
+
+const TagWrapper = styled.div`
+  height: 20px;
+  font-size: 12px;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  color: #ffffff44;
+  border: 1px solid #ffffff44;
+  border-radius: 3px;
+  padding-left: 6px;
+`;
+
+const BranchTag = styled.div`
+  height: 20px;
+  margin-left: 6px;
+  color: #aaaabb;
+  background: #ffffff22;
+  border-radius: 3px;
+  font-size: 12px;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  padding: 0px 6px;
+  padding-left: 7px;
+  border-top-left-radius: 0px;
+  border-bottom-left-radius: 0px;
+  white-space: nowrap;
+  overflow: hidden;
+  text-overflow: ellipsis;
+`;
+
+const BranchSection = styled.div`
+  background: ${(props) => props.theme.fg};
+  border: 1px solid #494b4f;
+`;
+
+const SmallIcon = styled.img<{ opacity?: string; height?: string }>`
+  height: ${(props) => props.height || "15px"};
+  opacity: ${(props) => props.opacity || 1};
+  margin-right: 10px;
+`;
+
+const BranchIcon = styled.img`
+  height: 14px;
+  opacity: 0.65;
+  margin-right: 5px;
+`;
+
 const Icon = styled.img`
   height: 24px;
   margin-right: 15px;
@@ -161,4 +869,35 @@ const Placeholder = styled.div`
 const StyledExpandedApp = styled.div`
   width: 100%;
   height: 100%;
-`;
+
+  animation: fadeIn 0.5s 0s;
+  @keyframes fadeIn {
+    from {
+      opacity: 0;
+    }
+    to {
+      opacity: 1;
+    }
+  }
+`;
+
+const HeaderWrapper = styled.div`
+  position: relative;
+`;
+const LastDeployed = styled.div`
+  font-size: 13px;
+  margin-left: 8px;
+  margin-top: -1px;
+  display: flex;
+  align-items: center;
+  color: #aaaabb66;
+`;
+const Dot = styled.div`
+  margin-right: 16px;
+`;
+const InfoWrapper = styled.div`
+  display: flex;
+  align-items: center;
+  margin-left: 3px;
+  margin-top: 22px;
+`;

+ 122 - 0
dashboard/src/main/home/app-dashboard/expanded-app/GHABanner.tsx

@@ -0,0 +1,122 @@
+import React, { useEffect, useState, useContext } from "react";
+import styled from "styled-components";
+
+import refresh from "assets/refresh.png";
+
+import { Context } from "shared/Context";
+
+import Banner from "components/porter/Banner";
+import Spacer from "components/porter/Spacer";
+import Link from "components/porter/Link";
+import GithubActionModal from "../new-app-flow/GithubActionModal";
+import Container from "components/porter/Container";
+
+
+type Props = {
+  pullRequestUrl: string;
+  branchName: string;
+  repoName: string;
+  stackName: string;
+  gitRepoId: number;
+};
+
+const GHABanner: React.FC<Props> = ({
+  pullRequestUrl,
+  branchName,
+  repoName,
+  stackName,
+  gitRepoId,
+}) => {
+  const { currentProject, currentCluster } = useContext(Context);
+  const [showGHAModal, setShowGHAModal] = useState(false);
+  return (
+    <>
+      <StyledGHABanner>
+        <>
+          {pullRequestUrl ? (
+            <Banner 
+              type="warning"
+              suffix={
+                <RefreshButton onClick={() => window.location.reload()}>
+                  <img src={refresh} /> Refresh
+                </RefreshButton>
+              }
+            >
+              <Container row spaced>
+                Your application will not be available until you merge
+                <Spacer inline width="5px" />
+                <Link
+                  to={pullRequestUrl}
+                  target="_blank"
+                  hasunderline
+                >
+                  this PR
+                </Link>
+                <Spacer inline width="5px" />
+                into your branch.
+              </Container>
+            </Banner>
+          ) : (
+            <Banner   
+              type="warning"
+              suffix={
+                <RefreshButton onClick={() => window.location.reload()}>
+                  <img src={refresh} /> Refresh
+                </RefreshButton>
+              }
+            >
+              Your application will not be available until you add the Porter workflow to your branch.
+              <Spacer inline width="5px" />
+              <Link
+                onClick={() => setShowGHAModal(true)}
+                target="_blank"
+                hasunderline
+              >
+                See details
+              </Link>
+            </Banner>
+          )}
+        </>
+      </StyledGHABanner>
+      {showGHAModal && (
+        <GithubActionModal
+          closeModal={() => setShowGHAModal(false)}
+          githubAppInstallationID={gitRepoId}
+          githubRepoOwner={repoName.split("/")[0]}
+          githubRepoName={repoName.split("/")[1]}
+          branch={branchName}
+          stackName={stackName}
+          projectId={currentProject.id}
+          clusterId={currentCluster.id}
+        />
+      )}
+    </>
+  );
+};
+
+export default GHABanner;
+
+const StyledGHABanner = styled.div`
+`;
+
+const RefreshButton = styled.div`
+  color: #ffffff44;
+  display: flex;
+  align-items: center;
+  cursor: pointer;
+  :hover {
+    color: #ffffff;
+    > img {
+      opacity: 1;
+    }
+  }
+
+  > img {
+    display: flex;
+    align-items: center;
+    justify-content: center;
+    height: 11px;
+    margin-right: 10px;
+    opacity: 0.3;
+  }
+`;

+ 652 - 0
dashboard/src/main/home/app-dashboard/expanded-app/LogSection.tsx

@@ -0,0 +1,652 @@
+import React, {
+  useCallback,
+  useContext,
+  useEffect,
+  useRef,
+  useState,
+} from "react";
+
+import styled from "styled-components";
+import RadioFilter from "components/RadioFilter";
+
+import filterOutline from "assets/filter-outline.svg";
+import time from "assets/time.svg";
+import { Context } from "shared/Context";
+import api from "shared/api";
+import { Direction, useLogs } from "./useAgentLogs";
+import Anser from "anser";
+import DateTimePicker from "components/date-time-picker/DateTimePicker";
+import dayjs from "dayjs";
+import Loading from "components/Loading";
+import _ from "lodash";
+import { ChartType } from "shared/types";
+import Banner from "components/porter/Banner";
+import LogSearchBar from "components/LogSearchBar";
+import LogQueryModeSelectionToggle from "components/LogQueryModeSelectionToggle";
+
+type Props = {
+  currentChart?: ChartType;
+};
+
+type PodFilter = {
+  podName: string;
+  podNamespace: string;
+};
+
+const LogSection: React.FC<Props> = ({ currentChart }) => {
+  const scrollToBottomRef = useRef<HTMLDivElement | undefined>(undefined);
+  const { currentProject, currentCluster } = useContext(Context);
+  const [podFilter, setPodFilter] = useState<PodFilter>({
+    podName: "",
+    podNamespace: "",
+  });
+  const [podFilterOpts, setPodFilterOpts] = useState<PodFilter[]>([]);
+  const [scrollToBottomEnabled, setScrollToBottomEnabled] = useState(true);
+  const [enteredSearchText, setEnteredSearchText] = useState("");
+  const [selectedDate, setSelectedDate] = useState<Date | undefined>(undefined);
+  const [notification, setNotification] = useState<string>();
+
+  const notify = (message: string) => {
+    setNotification(message);
+
+    setTimeout(() => {
+      setNotification(undefined);
+    }, 5000);
+  };
+
+  const { loading, logs, refresh, moveCursor, paginationInfo } = useLogs(
+    podFilter.podName,
+    podFilter.podNamespace,
+    enteredSearchText,
+    notify,
+    currentChart,
+    selectedDate
+  );
+
+  const refreshPodLogsValues = async () => {
+    // const filters = {
+    //   namespace: currentChart.namespace,
+    //   revision: currentChart.version.toString(),
+    //   match_prefix: currentChart.name,
+    // };
+
+    // const logPodValuesResp = await api.getLogPodValues("<TOKEN>", filters, {
+    //   project_id: currentProject.id,
+    //   cluster_id: currentCluster.id,
+    // });
+
+    // if (logPodValuesResp.data?.length != 0) {
+    //   setPodFilterOpts(
+    //     _.uniq(logPodValuesResp.data ?? []).map((podName: any) => {
+    //       return { podName: podName, podNamespace: currentChart.namespace };
+    //     })
+    //   );
+
+    //   // only set pod filter if the current pod is not found in the resulting data
+    //   if (!podFilter || !logPodValuesResp.data?.includes(podFilter)) {
+    //     setPodFilter({
+    //       podName: logPodValuesResp.data[0],
+    //       podNamespace: currentChart.namespace,
+    //     });
+    //   }
+    //   console.log("pod values set chart namespace", podFilter, podFilterOpts);
+    //   return;
+    // }
+
+    // // check if pods are in default namespace
+    // const filters_default = {
+    //   namespace: "default",
+    //   revision: currentChart.version.toString(),
+    //   match_prefix: currentChart.name,
+    // };
+
+    // const logPodValuesResp_default = await api.getLogPodValues(
+    //   "<TOKEN>",
+    //   filters_default,
+    //   {
+    //     project_id: currentProject.id,
+    //     cluster_id: currentCluster.id,
+    //   }
+    // );
+
+    // if (logPodValuesResp_default.data?.length != 0) {
+    //   setPodFilterOpts(
+    //     _.uniq(logPodValuesResp_default.data ?? []).map((podName: any) => {
+    //       return { podName: podName, podNamespace: "default" };
+    //     })
+    //   );
+
+    //   // only set pod filter if the current pod is not found in the resulting data
+    //   if (!podFilter || !logPodValuesResp_default.data?.includes(podFilter)) {
+    //     setPodFilter({
+    //       podName: logPodValuesResp_default.data[0],
+    //       podNamespace: "default",
+    //     });
+    //   }
+    //   console.log("pod values set default", podFilter, podFilterOpts);
+    //   return;
+    // }
+
+    // console.log("pod values empty");
+
+    // if we're on the latest revision and no pod values were returned, query for all release pods
+    if (currentChart.info.status == "deployed") {
+      console.log("search all releast pods");
+      const allReleasePodsResp = await api.getAllReleasePods(
+        "<TOKEN>",
+        {},
+        {
+          id: currentProject.id,
+          name: currentChart.name,
+          namespace: currentChart.namespace,
+          cluster_id: currentCluster.id,
+        }
+      );
+
+      let podList = allReleasePodsResp.data.map((pod: any) => {
+        return {
+          podName: pod.metadata.name,
+          podNamespace: pod.metadata.namespace,
+        };
+      });
+
+      setPodFilterOpts(podList);
+
+      if (!podFilter || !podList.includes(podFilter)) {
+        setPodFilter(podList[0]);
+      }
+    }
+  };
+
+  useEffect(() => {
+    refreshPodLogsValues();
+  }, []);
+
+  useEffect(() => {
+    if (!loading && scrollToBottomRef.current && scrollToBottomEnabled) {
+      scrollToBottomRef.current.scrollIntoView({
+        behavior: "smooth",
+        block: "end",
+      });
+    }
+  }, [loading, logs, scrollToBottomRef, scrollToBottomEnabled]);
+
+  const renderLogs = () => {
+    return logs?.map((log, i) => {
+      return (
+        <Log key={[log.lineNumber, i].join(".")}>
+          <span className="line-number">{log.lineNumber}.</span>
+          <span className="line-timestamp">
+            {log.timestamp
+              ? dayjs(log.timestamp).format("MMM D, YYYY HH:mm:ss")
+              : "-"}
+          </span>
+          <LogOuter key={[log.lineNumber, i].join(".")}>
+            {log.line?.map((ansi, j) => {
+              if (ansi.clearLine) {
+                return null;
+              }
+
+              return (
+                <LogInnerSpan
+                  key={[log.lineNumber, i, j].join(".")}
+                  ansi={ansi}
+                >
+                  {ansi.content.replace(/ /g, "\u00a0")}
+                </LogInnerSpan>
+              );
+            })}
+          </LogOuter>
+        </Log>
+      );
+    });
+  };
+
+  const setPodFilterWithPodName = (podName: string) => {
+    const filtered = podFilterOpts.filter((pod) => pod.podName == podName);
+    if (filtered.length > 0) {
+      console.log("setting filter");
+      setPodFilter(filtered[0]);
+    } else {
+      console.log("erroring filter");
+      setPodFilter({ podName: "", podNamespace: "" });
+    }
+  };
+
+  const onLoadPrevious = useCallback(() => {
+    if (!selectedDate) {
+      setSelectedDate(dayjs(logs[0].timestamp).toDate());
+      return;
+    }
+
+    moveCursor(Direction.backward);
+  }, [logs, selectedDate]);
+
+  const renderContents = () => {
+    const searchBarProps = {
+      // make sure all required component's inputs/Props keys&types match
+      setEnteredSearchText: setEnteredSearchText,
+    };
+    return (
+      <>
+        <FlexRow>
+          <Flex>
+            {/* <LogSearchBar setEnteredSearchText={setEnteredSearchText} /> */}
+            <LogQueryModeSelectionToggle
+              selectedDate={selectedDate}
+              setSelectedDate={setSelectedDate}
+            />
+            <RadioFilter
+              icon={filterOutline}
+              selected={podFilter.podName}
+              setSelected={setPodFilterWithPodName}
+              options={podFilterOpts?.map((pod) => {
+                return {
+                  value: pod.podName,
+                  label: pod.podName,
+                };
+              })}
+              name="Filter logs"
+            />
+          </Flex>
+          <Flex>
+            <Button onClick={() => setScrollToBottomEnabled((s) => !s)}>
+              <Checkbox checked={scrollToBottomEnabled}>
+                <i className="material-icons">done</i>
+              </Checkbox>
+              Scroll to bottom
+            </Button>
+            <Spacer />
+            <Button onClick={() => refresh()}>
+              <i className="material-icons">autorenew</i>
+              Refresh
+            </Button>
+          </Flex>
+        </FlexRow>
+        <LogsSectionWrapper>
+          <StyledLogsSection>
+            {loading || !logs.length ? (
+              <Loading message="Waiting for logs..." />
+            ) : (
+              <>
+                <LoadMoreButton
+                  active={
+                    logs.length !== 0 && paginationInfo.previousCursor !== null
+                  }
+                  role="button"
+                  onClick={onLoadPrevious}
+                >
+                  Load Previous
+                </LoadMoreButton>
+                {renderLogs()}
+                {/* <Message>
+            
+            No matching logs found.
+            <Highlight onClick={() => {}}>
+              <i className="material-icons">autorenew</i>
+              Refresh
+            </Highlight>
+          </Message> */}
+                <LoadMoreButton
+                  active={selectedDate && logs.length !== 0}
+                  role="button"
+                  onClick={() => moveCursor(Direction.forward)}
+                >
+                  Load more
+                </LoadMoreButton>
+              </>
+            )}
+            <div ref={scrollToBottomRef} />
+          </StyledLogsSection>
+          <NotificationWrapper
+            key={JSON.stringify(logs)}
+            active={!!notification}
+          >
+            <Banner>{notification}</Banner>
+          </NotificationWrapper>
+        </LogsSectionWrapper>
+      </>
+    );
+  };
+
+  return <>{renderContents()}</>;
+};
+
+export default LogSection;
+
+const BackButton = styled.div`
+  display: flex;
+  width: 30px;
+  z-index: 2;
+  cursor: pointer;
+  height: 30px;
+  align-items: center;
+  margin-right: 15px;
+  justify-content: center;
+  cursor: pointer;
+  border: 1px solid #ffffff55;
+  border-radius: 100px;
+  background: #ffffff11;
+
+  > i {
+    font-size: 18px;
+  }
+
+  :hover {
+    background: #ffffff22;
+    > img {
+      opacity: 1;
+    }
+  }
+`;
+
+const AbsoluteTitle = styled.div`
+  position: absolute;
+  top: 0px;
+  left: 0px;
+  width: 100%;
+  height: 60px;
+  display: flex;
+  align-items: center;
+  padding-left: 20px;
+  font-size: 18px;
+  font-weight: 500;
+  user-select: text;
+`;
+
+const Fullscreen = styled.div`
+  position: absolute;
+  top: 0;
+  left: 0;
+  width: 100%;
+  height: 100%;
+  padding-top: 60px;
+`;
+
+const Icon = styled.div`
+  background: #26292e;
+  border-radius: 5px;
+  height: 30px;
+  width: 30px;
+  display: flex;
+  cursor: pointer;
+  align-items: center;
+  justify-content: center;
+  > i {
+    font-size: 14px;
+  }
+  border: 1px solid #494b4f;
+  :hover {
+    border: 1px solid #7a7b80;
+  }
+`;
+
+const Checkbox = styled.div<{ checked: boolean }>`
+  width: 16px;
+  height: 16px;
+  border: 1px solid #ffffff55;
+  margin: 1px 10px 0px 1px;
+  border-radius: 3px;
+  background: ${(props) => (props.checked ? "#ffffff22" : "#ffffff11")};
+  display: flex;
+  align-items: center;
+  justify-content: center;
+
+  > i {
+    font-size: 12px;
+    padding-left: 0px;
+    display: ${(props) => (props.checked ? "" : "none")};
+  }
+`;
+
+const Spacer = styled.div<{ width?: string }>`
+  height: 100%;
+  width: ${(props) => props.width || "10px"};
+`;
+
+const Button = styled.div`
+  background: #26292e;
+  border-radius: 5px;
+  height: 30px;
+  font-size: 13px;
+  display: flex;
+  cursor: pointer;
+  align-items: center;
+  padding: 10px;
+  padding-left: 8px;
+  > i {
+    font-size: 16px;
+    margin-right: 5px;
+  }
+  border: 1px solid #494b4f;
+  :hover {
+    border: 1px solid #7a7b80;
+  }
+`;
+
+const Flex = styled.div`
+  display: flex;
+  align-items: center;
+  border-bottom: 25px solid transparent;
+`;
+
+const Message = styled.div`
+  display: flex;
+  height: 100%;
+  width: calc(100% - 150px);
+  align-items: center;
+  justify-content: center;
+  margin-left: 75px;
+  text-align: center;
+  color: #ffffff44;
+  font-size: 13px;
+`;
+
+const Highlight = styled.div`
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  margin-left: 8px;
+  color: #8590ff;
+  cursor: pointer;
+
+  > i {
+    font-size: 16px;
+    margin-right: 3px;
+  }
+`;
+
+const FlexRow = styled.div`
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  flex-wrap: wrap;
+`;
+
+const SearchBarWrapper = styled.div`
+  display: flex;
+  flex: 1;
+
+  > i {
+    color: #aaaabb;
+    padding-top: 1px;
+    margin-left: 8px;
+    font-size: 16px;
+    margin-right: 8px;
+  }
+`;
+
+const SearchInput = styled.input`
+  outline: none;
+  border: none;
+  font-size: 13px;
+  background: none;
+  width: 100%;
+  color: white;
+  height: 100%;
+`;
+
+const SearchRow = styled.div`
+  display: flex;
+  align-items: center;
+  height: 30px;
+  margin-right: 10px;
+  background: #26292e;
+  border-radius: 5px;
+  border: 1px solid #aaaabb33;
+`;
+
+const SearchRowWrapper = styled(SearchRow)`
+  border-radius: 5px;
+  width: 250px;
+`;
+
+const StyledLogsSection = styled.div`
+  width: 100%;
+  min-height: 400px;
+  height: calc(100vh - 460px);
+  display: flex;
+  flex-direction: column;
+  position: relative;
+  font-size: 13px;
+  border-radius: 8px;
+  border: 1px solid #ffffff33;
+  background: #000000;
+  animation: floatIn 0.3s;
+  animation-timing-function: ease-out;
+  animation-fill-mode: forwards;
+  overflow-y: auto;
+  overflow-wrap: break-word;
+  position: relative;
+  @keyframes floatIn {
+    from {
+      opacity: 0;
+      transform: translateY(10px);
+    }
+    to {
+      opacity: 1;
+      transform: translateY(0px);
+    }
+  }
+`;
+
+const Log = styled.div`
+  font-family: monospace;
+  user-select: text;
+  display: flex;
+  align-items: flex-end;
+  gap: 8px;
+  width: 100%;
+  & > * {
+    padding-block: 5px;
+  }
+  & > .line-timestamp {
+    height: 100%;
+    color: #949effff;
+    opacity: 0.5;
+    font-family: monospace;
+    min-width: fit-content;
+    padding-inline-end: 5px;
+  }
+  & > .line-number {
+    height: 100%;
+    background: #202538;
+    display: inline-block;
+    text-align: right;
+    min-width: 45px;
+    padding-inline-end: 5px;
+    opacity: 0.3;
+    font-family: monospace;
+  }
+`;
+
+const LogOuter = styled.div`
+  display: inline-block;
+  word-wrap: anywhere;
+  flex-grow: 1;
+  font-family: monospace, sans-serif;
+  font-size: 12px;
+`;
+
+const LogInnerSpan = styled.span`
+  font-family: monospace, sans-serif;
+  font-size: 12px;
+  font-weight: ${(props: { ansi: Anser.AnserJsonEntry }) =>
+    props.ansi?.decoration && props.ansi?.decoration == "bold" ? "700" : "400"};
+  color: ${(props: { ansi: Anser.AnserJsonEntry }) =>
+    props.ansi?.fg ? `rgb(${props.ansi?.fg})` : "white"};
+  background-color: ${(props: { ansi: Anser.AnserJsonEntry }) =>
+    props.ansi?.bg ? `rgb(${props.ansi?.bg})` : "transparent"};
+`;
+
+const LoadMoreButton = styled.div<{ active: boolean }>`
+  width: 100%;
+  display: ${(props) => (props.active ? "flex" : "none")};
+  justify-content: center;
+  align-items: center;
+  padding-block: 10px;
+  background: #1f2023;
+  cursor: pointer;
+  font-family: monospace;
+`;
+
+const ToggleOption = styled.div<{ selected: boolean; nudgeLeft?: boolean }>`
+  padding: 0 10px;
+  color: ${(props) => (props.selected ? "" : "#494b4f")};
+  border: 1px solid #494b4f;
+  height: 100%;
+  display: flex;
+  margin-left: ${(props) => (props.nudgeLeft ? "-1px" : "")};
+  align-items: center;
+  border-radius: ${(props) =>
+    props.nudgeLeft ? "0 5px 5px 0" : "5px 0 0 5px"};
+  :hover {
+    border: 1px solid #7a7b80;
+    z-index: 2;
+  }
+`;
+
+const ToggleButton = styled.div`
+  background: #26292e;
+  border-radius: 5px;
+  font-size: 13px;
+  height: 30px;
+  display: flex;
+  align-items: center;
+  cursor: pointer;
+`;
+
+const TimeIcon = styled.img<{ selected?: boolean }>`
+  width: 16px;
+  height: 16px;
+  z-index: 2;
+  opacity: ${(props) => (props.selected ? "" : "50%")};
+`;
+
+const NotificationWrapper = styled.div<{ active?: boolean }>`
+  position: absolute;
+  bottom: 10px;
+  display: ${(props) => (props.active ? "flex" : "none")};
+  justify-content: center;
+  align-items: center;
+  left: 50%;
+  transform: translateX(-50%);
+  width: fit-content;
+  background: #101420;
+  z-index: 9999;
+
+  @keyframes bounceIn {
+    0% {
+      transform: translateZ(-1400px);
+      opacity: 0;
+    }
+    100% {
+      transform: translateZ(0);
+      opacity: 1;
+    }
+  }
+`;
+
+const LogsSectionWrapper = styled.div`
+  position: relative;
+`;

+ 139 - 0
dashboard/src/main/home/app-dashboard/expanded-app/SharedBuildSettings.tsx

@@ -0,0 +1,139 @@
+import Input from "components/porter/Input";
+import Spacer from "components/porter/Spacer";
+import Text from "components/porter/Text";
+import ActionConfBranchSelector from "components/repo-selector/ActionConfBranchSelector";
+import ActionConfEditorStack from "components/repo-selector/ActionConfEditorStack";
+import DetectContentsList from "components/repo-selector/DetectContentsList";
+import React, { useEffect, useState } from "react";
+import AnimateHeight from "react-animate-height";
+import { ActionConfigType, BuildConfig } from "shared/types";
+import styled from "styled-components";
+
+type Props = {
+  actionConfig: ActionConfigType;
+  setActionConfig: (
+    x: ActionConfigType | ((prevState: ActionConfigType) => ActionConfigType)
+  ) => void;
+  branch: string;
+  setBranch: (x: string) => void;
+  dockerfilePath: string | null;
+  setDockerfilePath: (x: string) => void;
+  folderPath: string;
+  setFolderPath: (x: string) => void;
+  setBuildConfig: (x: any) => void;
+  porterYaml: string;
+  setPorterYaml: (x: any) => void;
+  imageUrl: string;
+  setImageUrl: (x: string) => void;
+};
+
+const SharedBuildSettings: React.FC<Props> = ({
+  actionConfig,
+  setActionConfig,
+  branch,
+  setBranch,
+  dockerfilePath,
+  setDockerfilePath,
+  folderPath,
+  setFolderPath,
+  setBuildConfig,
+  porterYaml,
+  setPorterYaml,
+  imageUrl,
+  setImageUrl,
+}) => {
+  const [isExpanded, setIsExpanded] = useState(false);
+
+  return (
+    <>
+      <Text size={16}>Build settings</Text>
+      <Spacer y={0.5} />
+      <Text color="helper">Select your Github repository.</Text>
+      <ActionConfEditorStack
+        actionConfig={actionConfig}
+        setActionConfig={(actionConfig: ActionConfigType) => {
+          setActionConfig((currentActionConfig: ActionConfigType) => ({
+            ...currentActionConfig,
+            ...actionConfig,
+          }));
+          setImageUrl(actionConfig.image_repo_uri);
+        }}
+        setBranch={setBranch}
+        setDockerfilePath={setDockerfilePath}
+        setFolderPath={setFolderPath}
+      />
+      <DarkMatter antiHeight="-4px" />
+      <Spacer y={0.3} />
+      {actionConfig.git_repo && (
+        <>
+          <Spacer y={1} />
+          <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={0.3} />
+      {actionConfig.git_repo && branch && (
+        <>
+          <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}
+          />
+          <DetectContentsList
+            actionConfig={actionConfig}
+            branch={branch}
+            dockerfilePath={dockerfilePath}
+            folderPath={folderPath}
+            setActionConfig={setActionConfig}
+            setDockerfilePath={setDockerfilePath}
+            setFolderPath={setFolderPath}
+            setBuildConfig={setBuildConfig}
+            porterYaml={porterYaml}
+            setPorterYaml={setPorterYaml}
+          />
+        </>
+      )}
+    </>
+  );
+};
+
+export default SharedBuildSettings;
+
+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;
+`;

+ 429 - 0
dashboard/src/main/home/app-dashboard/expanded-app/useAgentLogs.ts

@@ -0,0 +1,429 @@
+import Anser, { AnserJsonEntry } from "anser";
+import dayjs from "dayjs";
+import _ from "lodash";
+import { z } from "zod";
+import { useContext, useEffect, useRef, useState } from "react";
+import api from "shared/api";
+import { Context } from "shared/Context";
+import { useWebsockets, NewWebsocketOptions } from "shared/hooks/useWebsockets";
+import { ChartType } from "shared/types";
+import { isJSON } from "shared/util";
+
+const MAX_LOGS = 5000;
+const MAX_BUFFER_LOGS = 1000;
+const QUERY_LIMIT = 1000;
+
+export enum Direction {
+  forward = "forward",
+  backward = "backward",
+}
+
+export interface Log {
+  line: AnserJsonEntry[];
+  lineNumber: number;
+  timestamp?: string;
+}
+
+const LogSchema = z.object({
+  log: z.string(),
+  stream: z.string(),
+  time: z.string(),
+});
+
+type LogLine = z.infer<typeof LogSchema>;
+
+export const parseLogs = (logs: string[] = []): Log[] => {
+  return logs.filter(Boolean).map((logLine: string, idx) => {
+    try {
+      if (!isJSON(logLine)) {
+        return {
+          line: Anser.ansiToJson(logLine),
+          lineNumber: idx + 1,
+          timestamp: undefined,
+        };
+      }
+
+      const parsedLine: LogLine = JSON.parse(logLine);
+
+      LogSchema.parse(parsedLine);
+
+      // TODO Move log parsing to the render method
+      const ansiLog = Anser.ansiToJson(parsedLine.log);
+      return {
+        line: ansiLog,
+        lineNumber: idx + 1,
+        timestamp: parsedLine.time,
+      };
+    } catch (err) {
+      console.error(err, logLine);
+      return {
+        line: Anser.ansiToJson(logLine),
+        lineNumber: idx + 1,
+        timestamp: undefined,
+      };
+    }
+  });
+};
+
+interface PaginationInfo {
+  previousCursor: string | null;
+  nextCursor: string | null;
+}
+
+export const useLogs = (
+  currentPod: string,
+  namespace: string,
+  searchParam: string,
+  notify: (message: string) => void,
+  currentChart: ChartType,
+  // if setDate is set, results are not live
+  setDate?: Date
+) => {
+  console.log("calling useLogs", currentPod, namespace, searchParam);
+  const isLive = !setDate;
+  const logsBufferRef = useRef<Log[]>([]);
+  const { currentCluster, currentProject, setCurrentError } = useContext(
+    Context
+  );
+  const [logs, setLogs] = useState<Log[]>([]);
+  const [paginationInfo, setPaginationInfo] = useState<PaginationInfo>({
+    previousCursor: null,
+    nextCursor: null,
+  });
+  const [loading, setLoading] = useState(true);
+
+  // if we are live:
+  // - start date is initially set to 2 weeks ago
+  // - the query has an end date set to current date
+  // - moving the cursor forward does nothing
+
+  // if we are not live:
+  // - end date is set to the setDate
+  // - start date is initially set to 2 weeks ago, but then gets set to the
+  //   result of the initial query
+  // - moving the cursor both forward and backward changes the start and end dates
+
+  const {
+    newWebsocket,
+    openWebsocket,
+    closeWebsocket,
+    closeAllWebsockets,
+  } = useWebsockets();
+
+  const updateLogs = (
+    newLogs: Log[],
+    direction: Direction = Direction.forward
+  ) => {
+    // Nothing to update here
+    if (!newLogs.length) {
+      return;
+    }
+
+    setLogs((logs) => {
+      let updatedLogs = _.cloneDeep(logs);
+
+      /**
+       * If direction = Direction.forward, we want to append the new logs
+       * at the end of the current logs, else we want to append before the current logs
+       *
+       */
+      if (direction === Direction.forward) {
+        const lastLineNumber = updatedLogs.at(-1)?.lineNumber ?? 0;
+
+        updatedLogs.push(
+          ...newLogs.map((log, idx) => ({
+            ...log,
+            lineNumber: lastLineNumber + idx + 1,
+          }))
+        );
+
+        // For direction = Direction.forward, remove logs from the front
+        if (updatedLogs.length > MAX_LOGS) {
+          const logsToBeRemoved =
+            newLogs.length < MAX_BUFFER_LOGS ? newLogs.length : MAX_BUFFER_LOGS;
+          updatedLogs = updatedLogs.slice(logsToBeRemoved);
+        }
+      } else {
+        updatedLogs = newLogs.concat(
+          updatedLogs.map((log) => ({
+            ...log,
+            lineNumber: log.lineNumber + newLogs.length,
+          }))
+        );
+
+        // For direction = Direction.backward, remove logs from the back
+        if (updatedLogs.length > MAX_LOGS) {
+          const logsToBeRemoved =
+            newLogs.length < MAX_BUFFER_LOGS ? newLogs.length : MAX_BUFFER_LOGS;
+
+          updatedLogs = updatedLogs.slice(0, logsToBeRemoved);
+        }
+      }
+
+      return updatedLogs;
+    });
+  };
+
+  /**
+   * Flushes the logs buffer. If `discard` is true,
+   * it will update `current logs` before executing
+   * the flush operation
+   */
+  const flushLogsBuffer = (discard: boolean = false) => {
+    if (!discard) {
+      updateLogs(logsBufferRef.current ?? []);
+    }
+
+    logsBufferRef.current = [];
+  };
+
+  const pushLogs = (newLogs: Log[]) => {
+    logsBufferRef.current.push(...newLogs);
+
+    if (logsBufferRef.current.length >= MAX_BUFFER_LOGS) {
+      flushLogsBuffer();
+    }
+  };
+
+  const setupWebsocket = (websocketKey: string) => {
+    const websocketBaseURL = `/api/projects/${currentProject.id}/clusters/${currentCluster.id}/namespaces/${namespace}/logs/loki`;
+
+    const q = new URLSearchParams({
+      pod_selector: currentPod,
+      namespace,
+      search_param: searchParam,
+      revision: currentChart.version.toString(),
+    }).toString();
+
+    const endpoint = `${websocketBaseURL}?${q}`;
+
+    const config: NewWebsocketOptions = {
+      onopen: () => {
+        console.log("Opened websocket:", websocketKey);
+      },
+      onmessage: (evt: MessageEvent) => {
+        // Nothing to do here
+        if (!evt?.data || typeof evt.data !== "string") {
+          return;
+        }
+
+        const newLogs = parseLogs(
+          evt?.data?.split("}\n").map((line: string) => line + "}")
+        );
+
+        pushLogs(newLogs);
+      },
+      onclose: () => {
+        console.log("Closed websocket:", websocketKey);
+      },
+    };
+
+    newWebsocket(websocketKey, endpoint, config);
+    openWebsocket(websocketKey);
+  };
+
+  const queryLogs = (
+    startDate: string,
+    endDate: string,
+    direction: Direction,
+    limit: number = QUERY_LIMIT
+  ): Promise<{
+    logs: Log[];
+    previousCursor: string | null;
+    nextCursor: string | null;
+  }> => {
+    return api
+      .getLogs(
+        "<token>",
+        {
+          pod_selector: currentPod,
+          namespace,
+          revision: currentChart.version.toString(),
+          search_param: searchParam,
+          start_range: startDate,
+          end_range: endDate,
+          limit,
+          direction,
+        },
+        {
+          cluster_id: currentCluster.id,
+          project_id: currentProject.id,
+        }
+      )
+      .then((res) => {
+        const newLogs = parseLogs(
+          res.data.logs?.filter(Boolean).map((logLine: any) => logLine.line)
+        );
+
+        if (direction === Direction.backward) {
+          newLogs.reverse();
+        }
+
+        return {
+          logs: newLogs,
+          previousCursor:
+            // There are no more historical logs so don't set the previous cursor
+            newLogs.length < QUERY_LIMIT && direction == Direction.backward
+              ? null
+              : res.data.backward_continue_time,
+          nextCursor: res.data.forward_continue_time,
+        };
+      })
+      .catch((err) => {
+        setCurrentError(err);
+
+        return {
+          logs: [],
+          previousCursor: null,
+          nextCursor: null,
+        };
+      });
+  };
+
+  const refresh = async () => {
+    if (!currentPod) {
+      return;
+    }
+
+    setLoading(true);
+    setLogs([]);
+    flushLogsBuffer(true);
+    const websocketKey = `${currentPod}-${namespace}-websocket`;
+    const endDate = dayjs(setDate);
+    const twoWeeksAgo = endDate.subtract(14, "days");
+
+    const { logs: initialLogs, previousCursor, nextCursor } = await queryLogs(
+      twoWeeksAgo.toISOString(),
+      endDate.toISOString(),
+      Direction.backward
+    );
+
+    setPaginationInfo({
+      previousCursor,
+      nextCursor,
+    });
+
+    updateLogs(initialLogs);
+
+    if (!isLive && !initialLogs.length) {
+      notify(
+        "You have no logs for this time period. Try with a different time range."
+      );
+    }
+
+    closeWebsocket(websocketKey);
+
+    setLoading(false);
+
+    if (isLive) {
+      setupWebsocket(websocketKey);
+    }
+
+    return () => isLive && closeWebsocket(websocketKey);
+  };
+
+  const moveCursor = async (direction: Direction) => {
+    if (direction === Direction.backward) {
+      // we query by setting the endDate equal to the previous startDate, and setting the direction
+      // to "backward"
+      const refDate = paginationInfo.previousCursor ?? dayjs().toISOString();
+      const twoWeeksAgo = dayjs(refDate).subtract(14, "days");
+
+      const { logs: newLogs, previousCursor } = await queryLogs(
+        twoWeeksAgo.toISOString(),
+        refDate,
+        Direction.backward
+      );
+
+      const logsToUpdate = paginationInfo.previousCursor
+        ? newLogs.slice(0, -1)
+        : newLogs;
+
+      updateLogs(logsToUpdate, direction);
+
+      if (!logsToUpdate.length) {
+        notify("You have reached the beginning of the logs");
+      }
+
+      setPaginationInfo((paginationInfo) => ({
+        ...paginationInfo,
+        previousCursor,
+      }));
+    } else {
+      if (isLive) {
+        return;
+      }
+
+      // we query by setting the startDate equal to the previous endDate, setting the endDate equal to the
+      // current time, and setting the direction to "forward"
+      const refDate = paginationInfo.nextCursor ?? dayjs(setDate).toISOString();
+      const currDate = dayjs();
+
+      const { logs: newLogs, nextCursor } = await queryLogs(
+        refDate,
+        currDate.toISOString(),
+        Direction.forward
+      );
+
+      const logsToUpdate = paginationInfo.nextCursor
+        ? newLogs.slice(1)
+        : newLogs;
+
+      // If previously we had next cursor set, it is likely that the log might have a duplicate entry so we ignore the first line
+      updateLogs(logsToUpdate);
+
+      if (!logsToUpdate.length) {
+        notify("You are already at the latest logs");
+      }
+
+      setPaginationInfo((paginationInfo) => ({
+        ...paginationInfo,
+        nextCursor,
+      }));
+    }
+  };
+
+  useEffect(() => {
+    setLogs([]);
+    flushLogsBuffer(true);
+  }, []);
+
+  /**
+   * In some situations, we might never hit the limit for the max buffer size.
+   * An example is if the total logs for the pod < MAX_BUFFER_LOGS.
+   *
+   * For handling situations like this, we would want to force a flush operation
+   * on the buffer so that we dont have any stale logs
+   */
+  useEffect(() => {
+    /**
+     * We don't want users to wait for too long for the initial
+     * logs to appear. So we use a setTimeout for 1s to force-flush
+     * logs after 1s of load
+     */
+    setTimeout(flushLogsBuffer, 500);
+
+    const flushLogsBufferInterval = setInterval(flushLogsBuffer, 3000);
+
+    return () => clearInterval(flushLogsBufferInterval);
+  }, []);
+
+  useEffect(() => {
+    refresh();
+  }, [currentPod, namespace, searchParam, setDate]);
+
+  useEffect(() => {
+    // if the streaming is no longer live, close all websockets
+    if (!isLive) {
+      closeAllWebsockets();
+    }
+  }, [isLive]);
+
+  return {
+    logs,
+    refresh,
+    moveCursor,
+    paginationInfo,
+    loading,
+  };
+};

+ 32 - 27
dashboard/src/main/home/app-dashboard/new-app-flow/AdvancedBuildSettings.tsx

@@ -1,4 +1,4 @@
-import React, { useState } from "react";
+import React, { useEffect, useState } from "react";
 import styled from "styled-components";
 import Text from "components/porter/Text";
 import Spacer from "components/porter/Spacer";
@@ -7,7 +7,9 @@ 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";
+import { ActionConfigType, BuildConfig } from "shared/types";
+import SelectRow from "components/form-components/SelectRow";
+import Select from "components/porter/Select";
 
 interface AutoBuildpack {
   name?: string;
@@ -21,6 +23,8 @@ interface AdvancedBuildSettingsProps {
   actionConfig: ActionConfigType | null;
   branch: string;
   folderPath: string;
+  dockerfilePath?: string;
+  setDockerfilePath: (x: string) => void;
   setBuildConfig: (x: any) => void;
 }
 
@@ -39,27 +43,23 @@ const AdvancedBuildSettings: React.FC<AdvancedBuildSettingsProps> = (props) => {
   const handleSelectChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
     setBuildView(e.target.value);
   };
+  useEffect(() => {
+    if (props.dockerfilePath && props.dockerfilePath != "") {
+      setBuildView("docker");
+    } else {
+      setBuildView("buildpacks");
+    }
+  }, [props.dockerfilePath]);
   const createDockerView = () => {
     return (
       <>
-        <Text size={16}>Build with a Dockerfile</Text>
-        <Spacer y={0.5} />
-        <Text color="helper">Specify your Dockerfile path.</Text>
+        <Text color="helper">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="./"
+          value={props.dockerfilePath}
           width="300px"
-          setValue={(e) => {}}
+          setValue={props.setDockerfilePath}
         />
         <Spacer y={0.5} />
       </>
@@ -75,6 +75,7 @@ const AdvancedBuildSettings: React.FC<AdvancedBuildSettingsProps> = (props) => {
           folderPath={props.folderPath}
           onChange={(config) => {
             props.setBuildConfig(config);
+            props.setDockerfilePath("");
           }}
           hide={false}
         />
@@ -94,26 +95,29 @@ const AdvancedBuildSettings: React.FC<AdvancedBuildSettingsProps> = (props) => {
         {buildView == "docker" ? (
           <AdvancedBuildTitle>
             <i className="material-icons dropdown">arrow_drop_down</i>
-            Dockerfile Detected (configure Dockerfile Settings)
+            Configure Dockerfile settings
           </AdvancedBuildTitle>
         ) : (
           <AdvancedBuildTitle>
             <i className="material-icons dropdown">arrow_drop_down</i>
-            Configure Build Pack Settings
+            Configure buildpack 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} />
+          <Select
+            value={buildView}
+            width="300px"
+            options={[
+              { value: "docker", label: "Docker" },
+              { value: "buildpacks", label: "Buildpacks" },
+            ]}
+            setValue={(option) => setBuildView(option)}
+            label="Build method"
+          />
+          <Spacer y={1} />
           {buildView === "docker" ? createDockerView() : createBuildpackView()}
         </StyledSourceBox>
       </AnimateHeight>
@@ -134,6 +138,7 @@ const StyledAdvancedBuildSettings = styled.div`
   display: flex;
   justify-content: space-between;
   align-items: center;
+  margin-top: 15px;
   border-radius: 5px;
   height: 40px;
   font-size: 13px;
@@ -161,7 +166,7 @@ const AdvancedBuildTitle = styled.div`
 const StyledSourceBox = styled.div`
   width: 100%;
   color: #ffffff;
-  padding: 14px 35px 20px;
+  padding: 25px 35px 25px;
   position: relative;
   font-size: 13px;
   border-radius: 5px;

+ 122 - 64
dashboard/src/main/home/app-dashboard/new-app-flow/GithubActionModal.tsx

@@ -1,17 +1,24 @@
+import { RouteComponentProps, withRouter } from "react-router";
+import styled from "styled-components";
+import React from "react";
+
 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";
+import { getGithubAction } from "./utils";
+import AceEditor from "react-ace";
+import YamlEditor from "components/YamlEditor";
+import Error from "components/porter/Error";
+import Container from "components/porter/Container";
+import Checkbox from "components/porter/Checkbox";
 
-interface GithubActionModalProps {
+
+type Props = RouteComponentProps & {
   closeModal: () => void;
   githubAppInstallationID?: number;
   githubRepoOwner?: string;
@@ -20,12 +27,13 @@ interface GithubActionModalProps {
   stackName?: string;
   projectId?: number;
   clusterId?: number;
-  deployPorterApp: () => void;
+  deployPorterApp?: () => Promise<boolean>;
+  deploymentError?: string;
 }
 
 type Choice = "open_pr" | "copy";
 
-const GithubActionModal: React.FC<GithubActionModalProps> = ({
+const GithubActionModal: React.FC<Props> = ({
   closeModal,
   githubAppInstallationID,
   githubRepoOwner,
@@ -35,32 +43,58 @@ const GithubActionModal: React.FC<GithubActionModalProps> = ({
   projectId,
   clusterId,
   deployPorterApp,
+  deploymentError,
+  ...props
 }) => {
   const [choice, setChoice] = React.useState<Choice>("open_pr");
   const [loading, setLoading] = React.useState<boolean>(false);
-  const { currentProject, currentCluster } = useContext(Context);
+  const [isChecked, setIsChecked] = React.useState<boolean>(false);
 
   const submit = async () => {
-    if (githubAppInstallationID && githubRepoOwner && githubRepoName && branch && stackName) {
+    if (githubAppInstallationID && githubRepoOwner && githubRepoName && branch && stackName && projectId && clusterId) {
       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,
+        // this creates the dummy chart
+        var success = true;
+        if (deployPorterApp) {
+          success = await deployPorterApp();
+        }
+
+        if (success) {
+          // this creates the secret and possibly the PR
+          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" || isChecked),
+            },
+            {
+              project_id: projectId,
+              cluster_id: clusterId,
+              stack_name: stackName,
+            }
+          );
+          if (res?.data?.url) {
+            const updateRes = await api.updatePorterApp(
+              "<token>",
+              {
+                pull_request_url: res.data.url,
+              },
+              {
+                project_id: projectId,
+                cluster_id: clusterId,
+                name: stackName,
+              }
+            )
+            window.open(res.data.url, "_blank", "noreferrer");
+            if (!deployPorterApp) {
+              window.location.reload();
+            }
           }
-        );
-        if (res?.data?.url) {
-            window.open(res.data.url, "_blank", "noreferrer")
+          props.history.push(`/apps/${stackName}`);
         }
       } catch (error) {
         console.log(error)
@@ -78,60 +112,82 @@ const GithubActionModal: React.FC<GithubActionModalProps> = ({
       </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:
+        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} />
+      <Spacer y={0.5} />
       <ExpandableSection
         noWrapper
         expandText="[+] Show code"
         collapseText="[-] Hide code"
         Header={
-          <ModalHeader>./github/workflows/porter_deploy.yml</ModalHeader>
+          <ModalHeader>.github/workflows/porter.yml</ModalHeader>
         }
-        isInitiallyExpanded={true}
+        isInitiallyExpanded
+        spaced
+        copy={getGithubAction(projectId, clusterId, stackName, branch)}
         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>
-          </>
+          <YamlEditor
+            value={getGithubAction(projectId, clusterId, stackName, branch)}
+            readOnly={true}
+            height="300px"
+          />
         }
       />
       <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.
+        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 submitting 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>
+      {deployPorterApp ? (
+        <>
+          <Select
+            options={[
+              { label: "I authorize Porter to open a PR on my behalf (recommended)", value: "open_pr" },
+              { label: "I will copy the file into my repository myself", value: "copy" },
+            ]}
+            setValue={(x: string) => setChoice(x as Choice)}
+            width="100%"
+          />
+          <Spacer y={1} />
+          <Button
+            onClick={submit}
+            width={"110px"}
+            loadingText={"Submitting..."}
+            status={loading ? "loading" : deploymentError ? (
+              <Error message={deploymentError} />
+            ) : undefined}
+          >
+            Deploy app
+          </Button>
+        </>
+      ) : (
+        <>
+          <Checkbox
+            checked={isChecked}
+            toggleChecked={() => setIsChecked(!isChecked)}
+          >
+            <Text>
+              I authorize Porter to open a PR on my behalf
+            </Text>
+          </Checkbox>
+          <Spacer y={1} />
+          <Button
+            disabled={!isChecked}
+            onClick={submit}
+            loadingText={"Submitting..."}
+            status={loading ? "loading" : deploymentError ? (
+              <Error message={deploymentError} />
+            ) : undefined}
+          >
+            Open a PR for me
+          </Button>
+        </>
+      )}
     </Modal>
   )
 }
 
-export default GithubActionModal;
+export default withRouter(GithubActionModal);
 
 const Tab = styled.span`
   margin-left: 20px;
@@ -140,7 +196,9 @@ const Tab = styled.span`
 
 const ModalHeader = styled.div`
   font-weight: 600;
-  font-size: 20px;
-  font-family: monospace; ;
-
+  font-size: 16px;
+  font-family: monospace;
+  height: 40px;
+  display: flex;
+  align-items: center;
 `;

+ 204 - 0
dashboard/src/main/home/app-dashboard/new-app-flow/GithubConnectModal.tsx

@@ -0,0 +1,204 @@
+import { RouteComponentProps, withRouter } from "react-router";
+import styled from "styled-components";
+import React, { useEffect, useState } from "react";
+
+import Modal from "components/porter/Modal";
+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 Button from "components/porter/Button";
+
+import api from "shared/api";
+import Error from "components/porter/Error";
+
+import Helper from "components/form-components/Helper";
+import github from "assets/github-white.png";
+
+type Props = RouteComponentProps & {
+  closeModal: () => void;
+  hasClickedDoNotConnect: boolean;
+  handleDoNotConnect: () => void;
+};
+
+interface GithubAppAccessData {
+  username?: string;
+  accounts?: string[];
+}
+
+const GithubConnectModal: React.FC<Props> = ({
+  closeModal,
+  hasClickedDoNotConnect,
+  handleDoNotConnect,
+}) => {
+  const [accessLoading, setAccessLoading] = useState(true);
+  const [accessError, setAccessError] = useState(false);
+  const [accessData, setAccessData] = useState<GithubAppAccessData>({});
+  const [loading, setLoading] = React.useState<boolean>(false);
+  const url = `${window.location.protocol}//${window.location.host}${window.location.pathname}`;
+  const encoded_redirect_uri = encodeURIComponent(url);
+
+  const renderGithubConnect = () => {
+    const url = `${window.location.protocol}//${window.location.host}${window.location.pathname}`;
+    const encoded_redirect_uri = encodeURIComponent(url);
+
+    if (accessError) {
+      return (
+        <>
+          <ListWrapper>
+            <Helper>
+              No connected repositories found.
+              <A href={"/api/integrations/github-app/oauth"}>
+                Authorize Porter to view your repositories.
+              </A>
+            </Helper>
+          </ListWrapper>
+          <Spacer y={0.5} />
+
+          <Button
+            onClick={handleDoNotConnect}
+            width={"110px"}
+            loadingText={"Submitting..."}
+            status={loading ? "loading" : undefined}
+          >
+            Dismiss
+          </Button>
+        </>
+      );
+    } else if (!accessData.accounts || accessData.accounts?.length == 0) {
+      return (
+        <>
+          <Text size={16}>No connected repositories were found.</Text>
+          <Spacer y={0.5} />
+
+          <ButtonWrapper>
+            <ConnectToGithubButton
+              href={`/api/integrations/github-app/install?redirect_uri=${encoded_redirect_uri}`}
+            >
+              <GitHubIcon src={github} /> Connect to GitHub
+            </ConnectToGithubButton>
+
+            <Button
+              onClick={handleDoNotConnect}
+              width={"110px"}
+              loadingText={"Submitting..."}
+              status={loading ? "loading" : undefined}
+            >
+              Dismiss
+            </Button>
+          </ButtonWrapper>
+        </>
+      );
+    }
+  };
+  useEffect(() => {
+    api
+      .getGithubAccounts("<token>", {}, {})
+      .then(({ data }) => {
+        setAccessData(data);
+        setAccessLoading(false);
+      })
+      .catch(() => {
+        setAccessError(true);
+        setAccessLoading(false);
+      });
+  }, []);
+  return (
+    !hasClickedDoNotConnect &&
+    (accessError ||
+      !accessData.accounts ||
+      accessData.accounts?.length === 0) && (
+      <>
+        <Modal closeModal={closeModal}>
+          <>{renderGithubConnect()}</>
+        </Modal>
+      </>
+    )
+  );
+};
+
+export default withRouter(GithubConnectModal);
+
+const Tab = styled.span`
+  margin-left: 20px;
+  height: 1px;
+`;
+
+const ModalHeader = styled.div`
+  font-weight: 600;
+  font-size: 16px;
+  font-family: monospace;
+  height: 40px;
+  display: flex;
+  align-items: center;
+`;
+
+const ListWrapper = styled.div`
+  width: 100%;
+  height: 240px;
+  background: #ffffff11;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  border-radius: 5px;
+  margin-top: 20px;
+  padding: 40px;
+`;
+const A = styled.a`
+  color: #8590ff;
+  text-decoration: underline;
+  margin-left: 5px;
+  cursor: pointer;
+`;
+
+const ConnectToGithubButton = styled.a`
+  width: 180px;
+  justify-content: center;
+  border-radius: 5px;
+  display: flex;
+  flex-direction: row;
+  align-items: center;
+  font-size: 13px;
+  cursor: pointer;
+  font-family: "Work Sans", sans-serif;
+  color: white;
+  font-weight: 500;
+  padding: 10px;
+  overflow: hidden;
+  white-space: nowrap;
+  border: 1px solid #494b4f;
+  text-overflow: ellipsis;
+  cursor: ${(props: { disabled?: boolean }) =>
+    props.disabled ? "not-allowed" : "pointer"};
+
+  background: ${(props: { disabled?: boolean }) =>
+    props.disabled ? "#aaaabbee" : "#2E3338"};
+  :hover {
+    background: ${(props: { disabled?: boolean }) =>
+      props.disabled ? "" : "#353a3e"};
+  }
+
+  > i {
+    color: white;
+    width: 18px;
+    height: 18px;
+    font-weight: 600;
+    font-size: 12px;
+    border-radius: 20px;
+    display: flex;
+    align-items: center;
+    margin-right: 5px;
+    justify-content: center;
+  }
+`;
+
+const GitHubIcon = styled.img`
+  width: 20px;
+  filter: brightness(150%);
+  margin-right: 10px;
+`;
+const ButtonWrapper = styled.div`
+  display: flex;
+  justify-content: space-between;
+  margin-top: 20px;
+`;

+ 35 - 14
dashboard/src/main/home/app-dashboard/new-app-flow/JobTabs.tsx

@@ -5,15 +5,18 @@ import Spacer from "components/porter/Spacer";
 import TabSelector from "components/TabSelector";
 import Checkbox from "components/porter/Checkbox";
 import { JobService } from "./serviceTypes";
+import { Height } from "react-animate-height";
 
 interface Props {
-  service: JobService
-  editService: (service: JobService) => void
+  service: JobService;
+  editService: (service: JobService) => void;
+  setHeight: (height: Height) => void;
 }
 
 const JobTabs: React.FC<Props> = ({
   service,
-  editService
+  editService,
+  setHeight,
 }) => {
   const [currentTab, setCurrentTab] = React.useState<string>('main');
 
@@ -28,14 +31,17 @@ const JobTabs: React.FC<Props> = ({
           value={service.startCommand.value}
           width="300px"
           setValue={(e) => { editService({ ...service, startCommand: { readOnly: false, value: e } }) }}
+          disabledTooltip={"You may only edit this field in your porter.yaml."}
         />
         <Spacer y={1} />
         <Input
           label="Cron schedule (leave blank to run manually)"
           placeholder="ex: */5 * * * *"
-          value={service.cronSchedule}
+          value={service.cronSchedule.value}
+          disabled={service.cronSchedule.readOnly}
           width="300px"
-          setValue={(e) => { editService({ ...service, cronSchedule: e }) }}
+          setValue={(e) => { editService({ ...service, cronSchedule: { readOnly: false, value: e } }) }}
+          disabledTooltip={"You may only edit this field in your porter.yaml."}
         />
       </>
     )
@@ -46,19 +52,23 @@ const JobTabs: React.FC<Props> = ({
       <>
         <Spacer y={1} />
         <Input
-          label="CPUs"
+          label="CPUs (Mi)"
           placeholder="ex: 0.5"
-          value={service.cpu}
+          value={service.cpu.value}
+          disabled={service.cpu.readOnly}
           width="300px"
-          setValue={(e) => { editService({ ...service, cpu: e }) }}
+          setValue={(e) => { editService({ ...service, cpu: { readOnly: false, value: e } }) }}
+          disabledTooltip={"You may only edit this field in your porter.yaml."}
         />
         <Spacer y={1} />
         <Input
-          label="RAM (GB)"
+          label="RAM (MB)"
           placeholder="ex: 1"
-          value={service.ram}
+          value={service.ram.value}
+          disabled={service.ram.readOnly}
           width="300px"
-          setValue={(e) => { editService({ ...service, ram: e }) }}
+          setValue={(e) => { editService({ ...service, ram: { readOnly: false, value: e } }) }}
+          disabledTooltip={"You may only edit this field in your porter.yaml."}
         />
       </>
     )
@@ -69,8 +79,10 @@ const JobTabs: React.FC<Props> = ({
       <>
         <Spacer y={1} />
         <Checkbox
-          checked={service.jobsExecuteConcurrently}
-          toggleChecked={() => { editService({ ...service, jobsExecuteConcurrently: !service.jobsExecuteConcurrently }) }}
+          checked={service.jobsExecuteConcurrently.value}
+          toggleChecked={() => { editService({ ...service, jobsExecuteConcurrently: { readOnly: false, value: !service.jobsExecuteConcurrently.value } }) }}
+          disabled={service.jobsExecuteConcurrently.readOnly}
+          disabledTooltip={"You may only edit this field in your porter.yaml."}
         >
           <Text color="helper">Allow jobs to execute concurrently</Text>
         </Checkbox>
@@ -87,7 +99,16 @@ const JobTabs: React.FC<Props> = ({
           { label: 'Advanced', value: 'advanced' },
         ]}
         currentTab={currentTab}
-        setCurrentTab={setCurrentTab}
+        setCurrentTab={(value: string) => {
+          if (value === 'main') {
+            setHeight(244);
+          } else if (value === 'resources') {
+            setHeight(244);
+          } else if (value === 'advanced') {
+            setHeight(118.5);
+          }
+          setCurrentTab(value);
+        }}
       />
       {currentTab === 'main' && renderMain()}
       {currentTab === 'resources' && renderResources()}

+ 306 - 76
dashboard/src/main/home/app-dashboard/new-app-flow/NewAppFlow.tsx

@@ -1,48 +1,45 @@
-import React, { useEffect, useState, useContext, useMemo } from "react";
+import React, { useState, useContext, useEffect } from "react";
 import styled from "styled-components";
+import { RouteComponentProps, withRouter } from "react-router";
 import _ from "lodash";
 import yaml from "js-yaml";
+import github from "assets/github-white.png";
 
-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 DynamicLink from "components/DynamicLink";
+
 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,
+  RepoType,
 } from "shared/types";
+import Error from "components/porter/Error";
 import { z } from "zod";
-import { PorterYamlSchema } from "./schema";
-import { createDefaultService } from "./serviceTypes";
+import { PorterJson, PorterYamlSchema, createFinalPorterYaml } from "./schema";
+import { Service } from "./serviceTypes";
+import { Helper } from "components/form-components/Helper";
+import GithubConnectModal from "./GithubConnectModal";
 
 type Props = RouteComponentProps & {};
 
-const defaultActionConfig: GithubActionConfigType = {
+const defaultActionConfig: ActionConfigType = {
   git_repo: "",
   image_repo_uri: "",
   git_branch: "",
@@ -53,7 +50,7 @@ const defaultActionConfig: GithubActionConfigType = {
 interface FormState {
   applicationName: string;
   selectedSourceType: SourceType | undefined;
-  serviceList: any[];
+  serviceList: Service[];
   envVariables: KeyValueType[];
   releaseCommand: string;
 }
@@ -71,84 +68,130 @@ const Validators: {
 } = {
   applicationName: (value: string) => value.trim().length > 0,
   selectedSourceType: (value: SourceType | undefined) => value !== undefined,
-  serviceList: (value: any[]) => value.length > 0,
+  serviceList: (value: Service[]) => value.length > 0,
   envVariables: (value: KeyValueType[]) => true,
   releaseCommand: (value: string) => true,
 };
 
+type Detected = {
+  detected: boolean;
+  message: string;
+};
+
 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 [deploying, setDeploying] = useState<boolean>(false);
+  const [deploymentError, setDeploymentError] = useState<string | undefined>(
+    undefined
+  );
   const [currentStep, setCurrentStep] = useState<number>(0);
   const [existingStep, setExistingStep] = useState<number>(0);
   const [formState, setFormState] = useState<FormState>(INITIAL_STATE);
-  const [actionConfig, setActionConfig] = useState<GithubActionConfigType>({
+  const [actionConfig, setActionConfig] = useState<ActionConfigType>({
     ...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 [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 [showConnectModal, setConnectModal] = useState<boolean>(false);
+  const [hasClickedDoNotConnect, setHasClickedDoNotConnect] = useState(() =>
+    JSON.parse(localStorage.getItem("hasClickedDoNotConnect") || "false")
+  );
+
+  const [porterJson, setPorterJson] = useState<PorterJson | undefined>(
+    undefined
+  );
+  const [detected, setDetected] = useState<Detected | undefined>(undefined);
 
   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 porterYamlToJson = parsedData as PorterJson;
+      setPorterJson(porterYamlToJson);
       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 }))
+      const existingServices = formState.serviceList.map((s) => s.name);
+      for (const [name, app] of Object.entries(porterYamlToJson.apps)) {
+        if (!existingServices.includes(name)) {
+          if (app.type) {
+            newServices.push(Service.default(name, app.type, porterYamlToJson));
+          } else if (name.includes("web")) {
+            newServices.push(Service.default(name, "web", porterYamlToJson));
+          } else {
+            newServices.push(Service.default(name, "worker", porterYamlToJson));
+          }
         }
       }
-      setFormState({ ...formState, serviceList: [...formState.serviceList, ...newServices] });
-      if (Validators.serviceList(formState.serviceList)) {
+      const newServiceList = [...formState.serviceList, ...newServices];
+      setFormState({ ...formState, serviceList: newServiceList });
+      if (Validators.serviceList(newServiceList)) {
         setCurrentStep(Math.max(currentStep, 4));
       }
+      if (
+        porterYamlToJson &&
+        porterYamlToJson.apps &&
+        Object.keys(porterYamlToJson.apps).length > 0
+      ) {
+        setDetected({
+          detected: true,
+          message: `Detected ${
+            Object.keys(porterYamlToJson.apps).length
+          } apps from porter.yaml`,
+        });
+      } else {
+        setDetected({
+          detected: false,
+          message:
+            "Could not detect any apps from porter.yaml. Make sure it exists in the root of your repo.",
+        });
+      }
     } catch (error) {
-      console.log("Error converting porter yaml file to input: " + error)
+      console.log("Error converting porter yaml file to input: " + error);
     }
-  }
+  };
 
+  // const renderGithubConnect = () => {
+  //   const url = `${window.location.protocol}//${window.location.host}${window.location.pathname}`;
+  //   const encoded_redirect_uri = encodeURIComponent(url);
+
+  //   if (accessError) {
+  //     return (
+  //       <ListWrapper>
+  //         <Helper>
+  //           No connected repositories found.
+  //           <A href={"/api/integrations/github-app/oauth"}>
+  //             Authorize Porter to view your repositories.
+  //           </A>
+  //         </Helper>
+  //       </ListWrapper>
+  //     );
+  //   } else if (!accessData.accounts || accessData.accounts?.length == 0) {
+  //     return (
+  //       <>
+  //         <Text size={16}>No connected repositories were found.</Text>
+  //         <ConnectToGithubButton
+  //           href={`/api/integrations/github-app/install?redirect_uri=${encoded_redirect_uri}`}
+  //         >
+  //           <GitHubIcon src={github} /> Connect to GitHub
+  //         </ConnectToGithubButton>
+  //       </>
+  //     );
+  //   }
+  // };
   // Deploys a Helm chart and writes build settings to the DB
   const isAppNameValid = (name: string) => {
-    const regex = /^[a-z0-9-]+$/;
+    const regex = /^[a-z0-9-]{1,61}$/;
     return regex.test(name);
   };
-
   const handleAppNameChange = (name: string) => {
     setCurrentStep(currentStep);
     setFormState({ ...formState, applicationName: name });
@@ -163,39 +206,124 @@ const NewAppFlow: React.FC<Props> = ({ ...props }) => {
   const shouldHighlightAppNameInput = () => {
     return (
       formState.applicationName !== "" &&
-      !isAppNameValid(formState.applicationName)
+      (!isAppNameValid(formState.applicationName) ||
+        formState.applicationName.length > 61)
     );
   };
+  const handleDoNotConnect = () => {
+    setHasClickedDoNotConnect(true);
+    localStorage.setItem("hasClickedDoNotConnect", "true");
+  };
+
   const deployPorterApp = async () => {
     try {
-      // Write build settings to the DB
-      const res = await api.createPorterApp(
+      setDeploying(true);
+      setDeploymentError(undefined);
+      if (
+        currentProject == null ||
+        currentCluster == null ||
+        currentProject.id == null ||
+        currentCluster.id == null
+      ) {
+        throw "Project or cluster not found";
+      }
+
+      // validate form data
+      const finalPorterYaml = createFinalPorterYaml(
+        formState.serviceList,
+        formState.envVariables,
+        porterJson,
+        formState.applicationName,
+        currentProject.id,
+        currentCluster.id
+      );
+
+      const yamlString = yaml.dump(finalPorterYaml);
+      const base64Encoded = btoa(yamlString);
+      const imageInfo = imageUrl
+        ? {
+            image_info: {
+              repository: imageUrl,
+              tag: imageTag,
+            },
+          }
+        : {};
+
+      // create the dummy chart
+      await api.createPorterStack(
+        "<token>",
+        {
+          stack_name: formState.applicationName,
+          porter_yaml: base64Encoded,
+          ...imageInfo,
+        },
+        {
+          cluster_id: currentCluster.id,
+          project_id: currentProject.id,
+        }
+      );
+
+      // if success, write to the db
+      await api.createPorterApp(
         "<token>",
         {
           name: formState.applicationName,
           repo_name: actionConfig.git_repo,
           git_branch: branch,
+          git_repo_id: actionConfig?.git_repo_id,
           build_context: folderPath,
-          builder: "heroku",
-          buildpacks: "nodejs,ruby",
+          builder: (buildConfig as any)?.builder,
+          buildpacks: (buildConfig as any)?.buildpacks?.join(",") ?? "",
           dockerfile: dockerfilePath,
-
+          image_repo_uri: imageUrl,
         },
         {
           cluster_id: currentCluster.id,
           project_id: currentProject.id,
         }
       );
+
+      if (!actionConfig?.git_repo) {
+        props.history.push(`/apps/${formState.applicationName}`);
+      }
+      return true;
     } catch (err) {
+      // TODO: better error handling
       console.log(err);
-    }
+      const errMessage =
+        err?.response?.data?.error ??
+        err?.toString() ??
+        "An error occurred while deploying your app. Please try again.";
+      setDeploymentError(errMessage);
 
-    // TODO: update Porter stack
+      return false;
+    } finally {
+      setDeploying(false);
+    }
   };
 
+  // useEffect(() => {
+  //   api
+  //     .getGithubAccounts("<token>", {}, {})
+  //     .then(({ data }) => {
+  //       setAccessData(data);
+  //       setAccessLoading(false);
+  //     })
+  //     .catch(() => {
+  //       setAccessError(true);
+  //       setAccessLoading(false);
+  //     });
+  // }, []);
   return (
     <CenterWrapper>
       <Div>
+        {showConnectModal && (
+          <GithubConnectModal
+            closeModal={() => setConnectModal(false)}
+            hasClickedDoNotConnect={hasClickedDoNotConnect}
+            handleDoNotConnect={handleDoNotConnect}
+          />
+        )}
         <StyledConfigureTemplate>
           <Back to="/apps" />
           <DashboardHeader
@@ -214,14 +342,16 @@ const NewAppFlow: React.FC<Props> = ({ ...props }) => {
                 <Text color="helper">
                   Lowercase letters, numbers, and "-" only.
                 </Text>
-                <Spacer y={0.5}></Spacer>
+                <Spacer y={0.5} />
                 <Input
                   placeholder="ex: academic-sophon"
                   value={formState.applicationName}
                   width="300px"
                   error={
                     shouldHighlightAppNameInput() &&
-                    'Lowercase letters, numbers, and "-" only.'
+                    (formState.applicationName.length > 61
+                      ? "Maximum 61 characters allowed."
+                      : 'Lowercase letters, numbers, and "-" only.')
                   }
                   setValue={(e) => {
                     handleAppNameChange(e);
@@ -261,8 +391,6 @@ const NewAppFlow: React.FC<Props> = ({ ...props }) => {
                   setActionConfig={setActionConfig}
                   branch={branch}
                   setBranch={setBranch}
-                  procfileProcess={procfileProcess}
-                  setProcfileProcess={setProcfileProcess}
                   dockerfilePath={dockerfilePath}
                   setDockerfilePath={setDockerfilePath}
                   folderPath={folderPath}
@@ -272,21 +400,31 @@ const NewAppFlow: React.FC<Props> = ({ ...props }) => {
                   setBuildConfig={setBuildConfig}
                   porterYaml={porterYaml}
                   setPorterYaml={(newYaml: string) => {
-                    validatePorterYaml(newYaml)
+                    validatePorterYaml(newYaml);
                   }}
                 />
               </>,
               <>
-                <Text size={16}>Application services</Text>
+                <Text size={16}>
+                  Application services{" "}
+                  {detected && (
+                    <AppearingDiv>
+                      <Text
+                        color={detected.detected ? "#4797ff" : "#fcba03"}
+                      >
+                        {detected.detected ? (
+                          <I className="material-icons">check</I>
+                        ) : (
+                          <I className="material-icons">error</I>
+                        )}
+                        {detected.message}
+                      </Text>
+                    </AppearingDiv>
+                  )}
+                </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[]) => {
+                  setServices={(services: Service[]) => {
                     setFormState({ ...formState, serviceList: services });
                     if (Validators.serviceList(services)) {
                       setCurrentStep(Math.max(currentStep, 4));
@@ -336,9 +474,19 @@ const NewAppFlow: React.FC<Props> = ({ ...props }) => {
                   if (imageUrl) {
                     deployPorterApp();
                   } else {
+                    setDeploymentError(undefined);
                     setShowGHAModal(true);
                   }
                 }}
+                status={
+                  deploying ? (
+                    "loading"
+                  ) : deploymentError ? (
+                    <Error message={deploymentError} />
+                  ) : undefined
+                }
+                loadingText={"Deploying..."}
+                width={"120px"}
               >
                 Deploy app
               </Button>,
@@ -358,6 +506,7 @@ const NewAppFlow: React.FC<Props> = ({ ...props }) => {
           projectId={currentProject.id}
           clusterId={currentCluster.id}
           deployPorterApp={deployPorterApp}
+          deploymentError={deploymentError}
         />
       )}
     </CenterWrapper>
@@ -366,6 +515,11 @@ const NewAppFlow: React.FC<Props> = ({ ...props }) => {
 
 export default withRouter(NewAppFlow);
 
+const I = styled.i`
+  font-size: 18px;
+  margin-right: 5px;
+`;
+
 const Div = styled.div`
   width: 100%;
   max-width: 900px;
@@ -404,6 +558,9 @@ const Icon = styled.img`
 const AppearingDiv = styled.div`
   animation: floatIn 0.5s;
   animation-fill-mode: forwards;
+  display: flex;
+  align-items: center;
+  margin-left: 10px;
   @keyframes floatIn {
     from {
       opacity: 0;
@@ -419,3 +576,76 @@ const AppearingDiv = styled.div`
 const StyledConfigureTemplate = styled.div`
   height: 100%;
 `;
+
+const ExpandedWrapper = styled.div`
+  margin-top: 10px;
+  width: 100%;
+  border-radius: 3px;
+  border: 1px solid #ffffff44;
+  max-height: 275px;
+`;
+const ListWrapper = styled.div`
+  width: 100%;
+  height: 240px;
+  background: #ffffff11;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  border-radius: 5px;
+  margin-top: 20px;
+  padding: 40px;
+`;
+const A = styled.a`
+  color: #8590ff;
+  text-decoration: underline;
+  margin-left: 5px;
+  cursor: pointer;
+`;
+
+const ConnectToGithubButton = styled.a`
+  width: 180px;
+  justify-content: center;
+  border-radius: 5px;
+  display: flex;
+  flex-direction: row;
+  align-items: center;
+  font-size: 13px;
+  cursor: pointer;
+  font-family: "Work Sans", sans-serif;
+  color: white;
+  font-weight: 500;
+  padding: 10px;
+  overflow: hidden;
+  white-space: nowrap;
+  margin-top: 25px;
+  border: 1px solid #494b4f;
+  text-overflow: ellipsis;
+  cursor: ${(props: { disabled?: boolean }) =>
+    props.disabled ? "not-allowed" : "pointer"};
+
+  background: ${(props: { disabled?: boolean }) =>
+    props.disabled ? "#aaaabbee" : "#2E3338"};
+  :hover {
+    background: ${(props: { disabled?: boolean }) =>
+      props.disabled ? "" : "#353a3e"};
+  }
+
+  > i {
+    color: white;
+    width: 18px;
+    height: 18px;
+    font-weight: 600;
+    font-size: 12px;
+    border-radius: 20px;
+    display: flex;
+    align-items: center;
+    margin-right: 5px;
+    justify-content: center;
+  }
+`;
+
+const GitHubIcon = styled.img`
+  width: 20px;
+  filter: brightness(150%);
+  margin-right: 10px;
+`;

+ 10 - 8
dashboard/src/main/home/app-dashboard/new-app-flow/ServiceContainer.tsx

@@ -1,5 +1,5 @@
 import React from "react"
-import AnimateHeight from "react-animate-height";
+import AnimateHeight, { Height } from "react-animate-height";
 import styled from "styled-components";
 
 import web from "assets/web.png";
@@ -23,16 +23,18 @@ const ServiceContainer: React.FC<ServiceProps> = ({
   deleteService,
   editService,
 }) => {
-  const [showExpanded, setShowExpanded] = React.useState<boolean>(true)
+  const [showExpanded, setShowExpanded] = React.useState<boolean>(false);
+  const [height, setHeight] = React.useState<Height>('auto');
 
+  // TODO: calculate heights instead of hardcoding them
   const renderTabs = (service: Service) => {
     switch (service.type) {
       case 'web':
-        return <WebTabs service={service} editService={editService} />
+        return <WebTabs service={service} editService={editService} setHeight={setHeight} />
       case 'worker':
-        return <WorkerTabs service={service} editService={editService} />
+        return <WorkerTabs service={service} editService={editService} setHeight={setHeight} />
       case 'job':
-        return <JobTabs service={service} editService={editService} />
+        return <JobTabs service={service} editService={editService} setHeight={setHeight} />
     }
   }
 
@@ -60,14 +62,14 @@ const ServiceContainer: React.FC<ServiceProps> = ({
           {renderIcon(service)}
           {service.name.trim().length > 0 ? service.name : "New Service"}
         </ServiceTitle>
-        <ActionButton onClick={(e) => {
+        {service.canDelete && <ActionButton onClick={(e) => {
           deleteService();
         }}>
           <span className="material-icons">delete</span>
-        </ActionButton>
+        </ActionButton>}
       </ServiceHeader>
       <AnimateHeight
-        height={showExpanded ? "auto" : 0}
+        height={showExpanded ? height : 0}
       >
         <StyledSourceBox showExpanded={showExpanded}>
           {renderTabs(service)}

+ 34 - 11
dashboard/src/main/home/app-dashboard/new-app-flow/Services.tsx

@@ -1,4 +1,4 @@
-import React, { useState } from "react";
+import React, { useEffect, useState } from "react";
 import ServiceContainer from "./ServiceContainer";
 import styled from "styled-components";
 import Spacer from "components/porter/Spacer";
@@ -12,7 +12,7 @@ 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";
+import { Service, ServiceType } from "./serviceTypes";
 
 interface ServicesProps {
   services: Service[];
@@ -27,8 +27,13 @@ const Services: React.FC<ServicesProps> = ({ services, setServices }) => {
   const [serviceType, setServiceType] = useState<ServiceType>("web");
   const isServiceNameValid = (name: string) => {
     const regex = /^[a-z0-9-]+$/;
+
     return regex.test(name);
   };
+  const isServiceNameDuplicate = (name: string) => {
+    const serviceNames = services.map((service) => service.name);
+    return serviceNames.includes(name);
+  };
 
   return (
     <>
@@ -38,6 +43,7 @@ const Services: React.FC<ServicesProps> = ({ services, setServices }) => {
             {services.map((service, index) => {
               return (
                 <ServiceContainer
+                  key={service.name}
                   service={service}
                   editService={(newService: Service) =>
                     setServices(
@@ -54,12 +60,17 @@ const Services: React.FC<ServicesProps> = ({ services, setServices }) => {
           <Spacer y={0.5} />
         </>
       )}
-      <AddServiceButton onClick={() => setShowAddServiceModal(true)}>
+      <AddServiceButton
+        onClick={() => {
+          setShowAddServiceModal(true);
+          setServiceType("web");
+        }}
+      >
         <i className="material-icons add-icon">add_icon</i>
         Add a new service
       </AddServiceButton>
       {showAddServiceModal && (
-        <Modal closeModal={() => setShowAddServiceModal(false)}>
+        <Modal closeModal={() => setShowAddServiceModal(false)} width="500px">
           <Text size={16}>Add a new service</Text>
           <Spacer y={1} />
           <Text color="helper">Select a service type:</Text>
@@ -72,7 +83,7 @@ const Services: React.FC<ServicesProps> = ({ services, setServices }) => {
             </ServiceIcon>
             <Select
               value={serviceType}
-              // this is ugly
+              width="100%"
               setValue={(value: string) => setServiceType(value as ServiceType)}
               options={[
                 { label: "Web", value: "web" },
@@ -86,11 +97,15 @@ const Services: React.FC<ServicesProps> = ({ services, setServices }) => {
           <Spacer y={0.5} />
           <Input
             placeholder="ex: my-service"
-            width="300px"
+            width="100%"
             value={serviceName}
             error={
-              !isServiceNameValid(serviceName) &&
-              'Lowercase letters, numbers, and "-" only.'
+              (serviceName != "" &&
+                !isServiceNameValid(serviceName) &&
+                'Lowercase letters, numbers, and "-" only.') ||
+              (serviceName.length > 61 && "Must be 61 characters or less.") ||
+              (isServiceNameDuplicate(serviceName) &&
+                "Service name is duplicate")
             }
             setValue={setServiceName}
           />
@@ -99,13 +114,20 @@ const Services: React.FC<ServicesProps> = ({ services, setServices }) => {
             onClick={() => {
               setServices([
                 ...services,
-                createDefaultService(serviceName, serviceType, { readOnly: false, value: '' }),
+                Service.default(serviceName, serviceType, {
+                  readOnly: false,
+                  value: "",
+                }),
               ]);
               setShowAddServiceModal(false);
               setServiceName("");
               setServiceType("web");
             }}
-            disabled={!isServiceNameValid(serviceName)}
+            disabled={
+              !isServiceNameValid(serviceName) ||
+              isServiceNameDuplicate(serviceName) ||
+              serviceName?.length > 61
+            }
           >
             <I className="material-icons">add</I> Add service
           </Button>
@@ -124,6 +146,7 @@ const ServiceIcon = styled.div`
   justify-content: center;
   height: 35px;
   width: 35px;
+  min-width: 35px;
   margin-right: 10px;
   overflow: hidden;
   border-radius: 5px;
@@ -156,7 +179,7 @@ const ServicesContainer = styled.div``;
 
 const AddServiceButton = styled.div`
   color: #aaaabb;
-  background: #26292e;
+  background: ${({ theme }) => theme.fg};
   border: 1px solid #494b4f;
   :hover {
     border: 1px solid #7a7b80;

+ 102 - 89
dashboard/src/main/home/app-dashboard/new-app-flow/SourceSettings.tsx

@@ -7,12 +7,14 @@ 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 { ActionConfigType, BuildConfig } 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";
-
+import { pushFiltered } from "shared/routing";
+import ImageSelector from "components/image-selector/ImageSelector";
+import SharedBuildSettings from "../expanded-app/SharedBuildSettings";
 type Props = {
   source: SourceType | undefined;
   imageUrl: string;
@@ -23,8 +25,6 @@ type Props = {
   setActionConfig: (
     x: ActionConfigType | ((prevState: ActionConfigType) => ActionConfigType)
   ) => void;
-  procfileProcess: string;
-  setProcfileProcess: (x: string) => void;
   branch: string;
   setBranch: (x: string) => void;
   dockerfilePath: string | null;
@@ -46,100 +46,21 @@ const SourceSettings: React.FC<Props> = ({
   setImageTag,
   actionConfig,
   setActionConfig,
-  setProcfileProcess,
   branch,
   setBranch,
   dockerfilePath,
   setDockerfilePath,
-  procfilePath,
-  setProcfilePath,
   folderPath,
   setFolderPath,
   setBuildConfig,
   porterYaml,
   setPorterYaml,
+  ...props
 }) => {
-  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>
+        {/* /* <Text size={16}>Registry settings</Text>
         <Spacer y={0.5} />
         <Text color="helper">
           Specify the complete registry URL for your Docker image:
@@ -150,7 +71,40 @@ const SourceSettings: React.FC<Props> = ({
           value={imageUrl}
           width="300px"
           setValue={setImageUrl}
-        />
+        /> */}
+
+        <StyledSourceBox>
+          {/* <CloseButton
+            onClick={() => {
+              setSourceType("");
+              setImageUrl("");
+              setImageTag("");
+            }}
+          >
+            <i className="material-icons">close</i>
+          </CloseButton> */}
+          <Subtitle>
+            Specify the container image you would like to connect to this
+            template.
+            <Highlight
+              onClick={() =>
+                pushFiltered(props, "/integrations/registry", ["project_id"])
+              }
+            >
+              Manage Docker registries
+            </Highlight>
+            <Required>*</Required>
+          </Subtitle>
+          <DarkMatter antiHeight="-4px" />
+          <ImageSelector
+            selectedTag={imageTag}
+            selectedImageUrl={imageUrl}
+            setSelectedImageUrl={setImageUrl}
+            setSelectedTag={setImageTag}
+            forceExpanded={true}
+          />
+          <br />
+        </StyledSourceBox>
       </>
     );
   };
@@ -160,9 +114,25 @@ const SourceSettings: React.FC<Props> = ({
       {source && <Spacer y={1} />}
       <AnimateHeight height={source ? "auto" : 0}>
         <div>
-          {source === "github"
-            ? renderGithubSettings()
-            : renderDockerSettings()}
+          {source === "github" ? (
+            <SharedBuildSettings
+              actionConfig={actionConfig}
+              branch={branch}
+              dockerfilePath={dockerfilePath}
+              folderPath={folderPath}
+              setActionConfig={setActionConfig}
+              setDockerfilePath={setDockerfilePath}
+              setFolderPath={setFolderPath}
+              setBuildConfig={setBuildConfig}
+              porterYaml={porterYaml}
+              setPorterYaml={setPorterYaml}
+              setBranch={setBranch}
+              imageUrl={imageUrl}
+              setImageUrl={setImageUrl}
+            />
+          ) : (
+            renderDockerSettings()
+          )}
         </div>
       </AnimateHeight>
     </SourceSettingsContainer>
@@ -191,3 +161,46 @@ const Required = styled.div`
   color: #fc4976;
   display: inline-block;
 `;
+
+const CloseButton = styled.div`
+  position: absolute;
+  display: block;
+  width: 40px;
+  height: 40px;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  z-index: 1;
+  border-radius: 50%;
+  right: 12px;
+  top: 10px;
+  cursor: pointer;
+  :hover {
+    background-color: #ffffff11;
+  }
+
+  > i {
+    font-size: 20px;
+    color: #aaaabb;
+  }
+`;
+const Highlight = styled.a`
+  color: #8590ff;
+  text-decoration: none;
+  margin-left: 5px;
+  cursor: pointer;
+  display: inline;
+`;
+
+const StyledSourceBox = styled.div`
+  width: 100%;
+  color: #ffffff;
+  padding: 14px 35px 20px;
+  position: relative;
+  font-size: 13px;
+  margin-top: 6px;
+  margin-bottom: 25px;
+  border-radius: 5px;
+  background: ${(props) => props.theme.fg};
+  border: 1px solid #494b4f;
+`;

+ 62 - 26
dashboard/src/main/home/app-dashboard/new-app-flow/WebTabs.tsx

@@ -1,19 +1,23 @@
 import Input from "components/porter/Input";
-import React from "react"
+import React, { useEffect } 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";
+import { Height } from "react-animate-height";
+import Tooltip from "components/porter/Tooltip";
 
 interface Props {
   service: WebService
   editService: (service: WebService) => void
+  setHeight: (height: Height) => void
 }
 
 const WebTabs: React.FC<Props> = ({
   service,
-  editService
+  editService,
+  setHeight,
 }) => {
   const [currentTab, setCurrentTab] = React.useState<string>('main');
 
@@ -28,19 +32,24 @@ const WebTabs: React.FC<Props> = ({
           width="300px"
           disabled={service.startCommand.readOnly}
           setValue={(e) => { editService({ ...service, startCommand: { readOnly: false, value: e } }) }}
+          disabledTooltip={"You may only edit this field in your porter.yaml."}
         />
         <Spacer y={1} />
         <Input
           label="Container port"
           placeholder="ex: 80"
-          value={service.port}
+          value={service.port.value}
+          disabled={service.port.readOnly}
           width="300px"
-          setValue={(e) => { editService({ ...service, port: e }) }}
+          setValue={(e) => { editService({ ...service, port: { readOnly: false, value: e } }) }}
+          disabledTooltip={"You may only edit this field in your porter.yaml."}
         />
         <Spacer y={1} />
         <Checkbox
-          checked={service.generateUrlForExternalTraffic}
-          toggleChecked={() => { editService({ ...service, generateUrlForExternalTraffic: !service.generateUrlForExternalTraffic }) }}
+          checked={service.generateUrlForExternalTraffic.value}
+          disabled={service.generateUrlForExternalTraffic.readOnly}
+          toggleChecked={() => { editService({ ...service, generateUrlForExternalTraffic: { readOnly: false, value: !service.generateUrlForExternalTraffic.value } }) }}
+          disabledTooltip={"You may only edit this field in your porter.yaml."}
         >
           <Text color="helper">Generate a Porter URL for external traffic</Text>
         </Checkbox>
@@ -55,30 +64,38 @@ const WebTabs: React.FC<Props> = ({
         <Input
           label="CPUs"
           placeholder="ex: 0.5"
-          value={service.cpu}
+          value={service.cpu.value}
+          disabled={service.cpu.readOnly}
           width="300px"
-          setValue={(e) => { editService({ ...service, cpu: e }) }}
+          setValue={(e) => { editService({ ...service, cpu: { readOnly: false, value: e } }) }}
+          disabledTooltip={"You may only edit this field in your porter.yaml."}
         />
         <Spacer y={1} />
         <Input
-          label="RAM (GB)"
+          label="RAM (MB)"
           placeholder="ex: 1"
-          value={service.ram}
+          value={service.ram.value}
+          disabled={service.ram.readOnly}
           width="300px"
-          setValue={(e) => { editService({ ...service, ram: e }) }}
+          setValue={(e) => { editService({ ...service, ram: { readOnly: false, value: e } }) }}
+          disabledTooltip={"You may only edit this field in your porter.yaml."}
         />
         <Spacer y={1} />
         <Input
           label="Replicas"
           placeholder="ex: 1"
-          value={service.replicas}
+          value={service.replicas.value}
+          disabled={service.replicas.readOnly || service.autoscalingOn.value}
           width="300px"
-          setValue={(e) => { editService({ ...service, replicas: e }) }}
+          setValue={(e) => { editService({ ...service, replicas: { readOnly: false, value: e } }) }}
+          disabledTooltip={service.replicas.readOnly ? "You may only edit this field in your porter.yaml." : "Disable autoscaling to specify replicas."}
         />
         <Spacer y={1} />
         <Checkbox
-          checked={service.autoscalingOn}
-          toggleChecked={() => { editService({ ...service, autoscalingOn: !service.autoscalingOn }) }}
+          checked={service.autoscalingOn.value}
+          toggleChecked={() => { editService({ ...service, autoscalingOn: { readOnly: false, value: !service.autoscalingOn.value } }) }}
+          disabled={service.autoscalingOn.readOnly}
+          disabledTooltip={"You may only edit this field in your porter.yaml."}
         >
           <Text color="helper">Enable autoscaling (overrides replicas)</Text>
         </Checkbox>
@@ -86,33 +103,41 @@ const WebTabs: React.FC<Props> = ({
         <Input
           label="Min replicas"
           placeholder="ex: 1"
-          value={service.minReplicas}
+          value={service.minReplicas.value}
+          disabled={service.minReplicas.readOnly || !service.autoscalingOn.value}
           width="300px"
-          setValue={(e) => { editService({ ...service, minReplicas: e }) }}
+          setValue={(e) => { editService({ ...service, minReplicas: { readOnly: false, value: e } }) }}
+          disabledTooltip={service.minReplicas.readOnly ? "You may only edit this field in your porter.yaml." : "Enable autoscaling to specify min replicas."}
         />
         <Spacer y={1} />
         <Input
           label="Max replicas"
           placeholder="ex: 10"
-          value={service.maxReplicas}
+          value={service.maxReplicas.value}
+          disabled={service.maxReplicas.readOnly || !service.autoscalingOn.value}
           width="300px"
-          setValue={(e) => { editService({ ...service, maxReplicas: e }) }}
+          setValue={(e) => { editService({ ...service, maxReplicas: { readOnly: false, value: e } }) }}
+          disabledTooltip={service.maxReplicas.readOnly ? "You may only edit this field in your porter.yaml." : "Enable autoscaling to specify max replicas."}
         />
         <Spacer y={1} />
         <Input
           label="Target CPU utilization (%)"
           placeholder="ex: 50"
-          value={service.targetCPUUtilizationPercentage}
+          value={service.targetCPUUtilizationPercentage.value}
+          disabled={service.targetCPUUtilizationPercentage.readOnly || !service.autoscalingOn.value}
           width="300px"
-          setValue={(e) => { editService({ ...service, targetCPUUtilizationPercentage: e }) }}
+          setValue={(e) => { editService({ ...service, targetCPUUtilizationPercentage: { readOnly: false, value: e } }) }}
+          disabledTooltip={service.targetCPUUtilizationPercentage.readOnly ? "You may only edit this field in your porter.yaml." : "Enable autoscaling to specify target CPU utilization."}
         />
         <Spacer y={1} />
         <Input
           label="Target RAM utilization (%)"
           placeholder="ex: 50"
-          value={service.targetRAMUtilizationPercentage}
+          value={service.targetRAMUtilizationPercentage.value}
+          disabled={service.targetRAMUtilizationPercentage.readOnly || !service.autoscalingOn.value}
           width="300px"
-          setValue={(e) => { editService({ ...service, targetRAMUtilizationPercentage: e }) }}
+          setValue={(e) => { editService({ ...service, targetRAMUtilizationPercentage: { readOnly: false, value: e } }) }}
+          disabledTooltip={service.targetRAMUtilizationPercentage.readOnly ? "You may only edit this field in your porter.yaml." : "Enable autoscaling to specify target RAM utilization."}
         />
       </>
     )
@@ -125,9 +150,11 @@ const WebTabs: React.FC<Props> = ({
         <Input
           label="Custom domain"
           placeholder="ex: my-app.my-domain.com"
-          value={service.customDomain ?? ''}
+          value={service.customDomain.value}
+          disabled={service.customDomain.readOnly}
           width="300px"
-          setValue={(e) => { editService({ ...service, customDomain: e }) }}
+          setValue={(e) => { editService({ ...service, customDomain: { readOnly: false, value: e } }) }}
+          disabledTooltip={"You may only edit this field in your porter.yaml."}
         />
       </>
     );
@@ -142,7 +169,16 @@ const WebTabs: React.FC<Props> = ({
           { label: 'Advanced', value: 'advanced' },
         ]}
         currentTab={currentTab}
-        setCurrentTab={setCurrentTab}
+        setCurrentTab={(value: string) => {
+          if (value === 'main') {
+            setHeight(300);
+          } else if (value === 'resources') {
+            setHeight(713.5);
+          } else if (value === 'advanced') {
+            setHeight(159);
+          }
+          setCurrentTab(value);
+        }}
       />
       {currentTab === 'main' && renderMain()}
       {currentTab === 'resources' && renderResources()}

+ 46 - 28
dashboard/src/main/home/app-dashboard/new-app-flow/WorkerTabs.tsx

@@ -5,15 +5,18 @@ import Spacer from "components/porter/Spacer";
 import TabSelector from "components/TabSelector";
 import Checkbox from "components/porter/Checkbox";
 import { WorkerService } from "./serviceTypes";
+import { Height } from "react-animate-height";
 
 interface Props {
   service: WorkerService
   editService: (service: WorkerService) => void
+  setHeight: (height: Height) => void
 }
 
 const WorkerTabs: React.FC<Props> = ({
   service,
-  editService
+  editService,
+  setHeight
 }) => {
   const [currentTab, setCurrentTab] = React.useState<string>('main');
 
@@ -28,6 +31,7 @@ const WorkerTabs: React.FC<Props> = ({
           value={service.startCommand.value}
           width="300px"
           setValue={(e) => { editService({ ...service, startCommand: { readOnly: false, value: e } }) }}
+          disabledTooltip={"You may only edit this field in your porter.yaml."}
         />
       </>
     )
@@ -40,30 +44,38 @@ const WorkerTabs: React.FC<Props> = ({
         <Input
           label="CPUs"
           placeholder="ex: 0.5"
-          value={service.cpu}
+          value={service.cpu.value}
+          disabled={service.cpu.readOnly}
           width="300px"
-          setValue={(e) => { editService({ ...service, cpu: e }) }}
+          setValue={(e) => { editService({ ...service, cpu: { readOnly: false, value: e } }) }}
+          disabledTooltip={"You may only edit this field in your porter.yaml."}
         />
         <Spacer y={1} />
         <Input
-          label="RAM (GB)"
+          label="RAM (MB)"
           placeholder="ex: 1"
-          value={service.ram}
+          value={service.ram.value}
+          disabled={service.ram.readOnly}
           width="300px"
-          setValue={(e) => { editService({ ...service, ram: e }) }}
+          setValue={(e) => { editService({ ...service, ram: { readOnly: false, value: e } }) }}
+          disabledTooltip={"You may only edit this field in your porter.yaml."}
         />
         <Spacer y={1} />
         <Input
           label="Replicas"
           placeholder="ex: 1"
-          value={service.replicas}
+          value={service.replicas.value}
+          disabled={service.replicas.readOnly || service.autoscalingOn.value}
           width="300px"
-          setValue={(e) => { editService({ ...service, replicas: e }) }}
+          setValue={(e) => { editService({ ...service, replicas: { readOnly: false, value: e } }) }}
+          disabledTooltip={service.replicas.readOnly ? "You may only edit this field in your porter.yaml." : "Disable autoscaling to specify replicas."}
         />
         <Spacer y={1} />
         <Checkbox
-          checked={service.autoscalingOn}
-          toggleChecked={() => { editService({ ...service, autoscalingOn: !service.autoscalingOn }) }}
+          checked={service.autoscalingOn.value}
+          toggleChecked={() => { editService({ ...service, autoscalingOn: { readOnly: false, value: !service.autoscalingOn.value } }) }}
+          disabled={service.autoscalingOn.readOnly}
+          disabledTooltip={"You may only edit this field in your porter.yaml."}
         >
           <Text color="helper">Enable autoscaling (overrides replicas)</Text>
         </Checkbox>
@@ -71,59 +83,65 @@ const WorkerTabs: React.FC<Props> = ({
         <Input
           label="Min replicas"
           placeholder="ex: 1"
-          value={service.minReplicas}
+          value={service.minReplicas.value}
+          disabled={service.minReplicas.readOnly || !service.autoscalingOn.value}
           width="300px"
-          setValue={(e) => { editService({ ...service, minReplicas: e }) }}
+          setValue={(e) => { editService({ ...service, minReplicas: { readOnly: false, value: e } }) }}
+          disabledTooltip={service.minReplicas.readOnly ? "You may only edit this field in your porter.yaml." : "Enable autoscaling to specify min replicas."}
         />
         <Spacer y={1} />
         <Input
           label="Max replicas"
           placeholder="ex: 10"
-          value={service.maxReplicas}
+          value={service.maxReplicas.value}
+          disabled={service.maxReplicas.readOnly || !service.autoscalingOn.value}
           width="300px"
-          setValue={(e) => { editService({ ...service, maxReplicas: e }) }}
+          setValue={(e) => { editService({ ...service, maxReplicas: { readOnly: false, value: e } }) }}
+          disabledTooltip={service.maxReplicas.readOnly ? "You may only edit this field in your porter.yaml." : "Enable autoscaling to specify max replicas."}
         />
         <Spacer y={1} />
         <Input
           label="Target CPU utilization (%)"
           placeholder="ex: 50"
-          value={service.targetCPUUtilizationPercentage}
+          value={service.targetCPUUtilizationPercentage.value}
+          disabled={service.targetCPUUtilizationPercentage.readOnly || !service.autoscalingOn.value}
           width="300px"
-          setValue={(e) => { editService({ ...service, targetCPUUtilizationPercentage: e }) }}
+          setValue={(e) => { editService({ ...service, targetCPUUtilizationPercentage: { readOnly: false, value: e } }) }}
+          disabledTooltip={service.targetCPUUtilizationPercentage.readOnly ? "You may only edit this field in your porter.yaml." : "Enable autoscaling to specify target CPU utilization."}
         />
         <Spacer y={1} />
         <Input
           label="Target RAM utilization (%)"
           placeholder="ex: 50"
-          value={service.targetRAMUtilizationPercentage}
+          value={service.targetRAMUtilizationPercentage.value}
+          disabled={service.targetRAMUtilizationPercentage.readOnly || !service.autoscalingOn.value}
           width="300px"
-          setValue={(e) => { editService({ ...service, targetRAMUtilizationPercentage: e }) }}
+          setValue={(e) => { editService({ ...service, targetRAMUtilizationPercentage: { readOnly: false, value: e } }) }}
+          disabledTooltip={service.targetRAMUtilizationPercentage.readOnly ? "You may only edit this field in your porter.yaml." : "Enable autoscaling to specify target RAM utilization."}
         />
       </>
     )
   };
 
-  const renderAdvanced = () => {
-    return (
-      <>
-      </>
-    );
-  };
-
   return (
     <>
       <TabSelector
         options={[
           { label: 'Main', value: 'main' },
           { label: 'Resources', value: 'resources' },
-          // { label: 'Advanced', value: 'advanced' },
         ]}
         currentTab={currentTab}
-        setCurrentTab={setCurrentTab}
+        setCurrentTab={(value: string) => {
+          if (value === 'main') {
+            setHeight(159);
+          } else if (value === 'resources') {
+            setHeight(713.5);
+          }
+          setCurrentTab(value);
+        }}
       />
       {currentTab === 'main' && renderMain()}
       {currentTab === 'resources' && renderResources()}
-      {/* currentTab === 'advanced' && renderAdvanced() */}
     </>
   )
 }

+ 86 - 6
dashboard/src/main/home/app-dashboard/new-app-flow/schema.tsx

@@ -1,4 +1,7 @@
+import { KeyValueType } from "main/home/cluster-dashboard/env-groups/EnvGroupArray";
 import * as z from "zod";
+import { Service } from "./serviceTypes";
+import { overrideObjectValues } from "./utils";
 
 const appConfigSchema = z.object({
     run: z.string().min(1),
@@ -6,11 +9,11 @@ const appConfigSchema = z.object({
     type: z.enum(['web', 'worker', 'job']).optional(),
 });
 
-const appsSchema = z.record(appConfigSchema);
+export const AppsSchema = z.record(appConfigSchema);
 
-const envSchema = z.record(z.string());
+export const EnvSchema = z.record(z.string());
 
-const buildSchema = z.object({
+export const BuildSchema = z.object({
     method: z.string().refine(value => ["pack", "docker", "registry"].includes(value)),
     context: z.string().optional(),
     builder: z.string().optional(),
@@ -34,8 +37,85 @@ const buildSchema = z.object({
 
 export const PorterYamlSchema = z.object({
     version: z.string().optional(),
-    build: buildSchema.optional(),
-    env: envSchema.optional(),
-    apps: appsSchema,
+    build: BuildSchema.optional(),
+    env: EnvSchema.optional(),
+    apps: AppsSchema,
     release: z.string().optional(),
 });
+
+export const createFinalPorterYaml = (
+    services: Service[],
+    dashboardSetEnvVariables: KeyValueType[],
+    porterJson: PorterJson | undefined,
+    stackName: string,
+    projectId: number,
+    clusterId: number,
+): PorterJson => {
+    return {
+        version: "v1stack",
+        env: combineEnv(dashboardSetEnvVariables, porterJson?.env),
+        apps: createApps(services, porterJson, stackName, projectId, clusterId),
+    };
+};
+
+const combineEnv = (
+    dashboardSetVariables: KeyValueType[],
+    porterYamlSetVariables: Record<string, string> | undefined
+): z.infer<typeof EnvSchema> => {
+    const env: z.infer<typeof EnvSchema> = {};
+    for (const { key, value } of dashboardSetVariables) {
+        env[key] = value;
+    }
+    if (porterYamlSetVariables != null) {
+        for (const [key, value] of Object.entries(porterYamlSetVariables)) {
+            env[key] = value;
+        }
+    }
+    return env;
+};
+
+const createApps = (
+    serviceList: Service[],
+    porterJson: PorterJson | undefined,
+    stackName: string,
+    projectId: number,
+    clusterId: number,
+): z.infer<typeof AppsSchema> => {
+    const apps: z.infer<typeof AppsSchema> = {};
+    for (const service of serviceList) {
+        let config = Service.serialize(service);
+        // TODO: get rid of this block when we handle ingress on the backend
+        if (Service.isWeb(service)) {
+            const ingress = Service.handleWebIngress(
+                service,
+                stackName,
+                clusterId,
+                projectId
+            );
+            config = {
+                ...config,
+                ...ingress,
+            };
+        }
+        if (
+            porterJson != null &&
+            porterJson.apps[service.name] != null &&
+            porterJson.apps[service.name].config != null
+        ) {
+            config = overrideObjectValues(
+                config,
+                porterJson.apps[service.name].config
+            );
+        }
+        // required because of https://github.com/helm/helm/issues/9214
+        apps[Service.toHelmName(service)] = {
+            type: service.type,
+            run: service.startCommand.value,
+            config,
+        };
+    }
+
+    return apps;
+};
+
+export type PorterJson = z.infer<typeof PorterYamlSchema>;

+ 355 - 51
dashboard/src/main/home/app-dashboard/new-app-flow/serviceTypes.ts

@@ -1,92 +1,396 @@
+import _ from "lodash";
+import { overrideObjectValues } from "./utils";
+import { KeyValueType } from "main/home/cluster-dashboard/env-groups/EnvGroupArray";
+import { PorterJson } from "./schema";
+
 export type Service = WorkerService | WebService | JobService;
 export type ServiceType = 'web' | 'worker' | 'job';
 
-type ServiceReadOnlyField = {
+type ServiceString = {
     readOnly: boolean;
     value: string;
 }
+type ServiceBoolean = {
+    readOnly: boolean;
+    value: boolean;
+}
+
+const ServiceField = {
+    string: (defaultValue: string, overrideValue?: string): ServiceString => {
+        return {
+            readOnly: overrideValue != null,
+            value: overrideValue ?? defaultValue,
+        }
+    },
+    boolean: (defaultValue: boolean, overrideValue?: boolean): ServiceBoolean => {
+        return {
+            readOnly: overrideValue != null,
+            value: overrideValue ?? defaultValue,
+        }
+    },
+}
 
 type SharedServiceParams = {
     name: string;
-    cpu: string;
-    ram: string;
-    startCommand: ServiceReadOnlyField;
+    cpu: ServiceString;
+    ram: ServiceString;
+    startCommand: ServiceString;
     type: ServiceType;
+    canDelete: boolean;
 }
 
 export type WorkerService = SharedServiceParams & {
     type: 'worker';
-    replicas: string;
-    autoscalingOn: boolean;
-    minReplicas: string;
-    maxReplicas: string;
-    targetCPUUtilizationPercentage: string;
-    targetRAMUtilizationPercentage: string;
+    replicas: ServiceString;
+    autoscalingOn: ServiceBoolean;
+    minReplicas: ServiceString;
+    maxReplicas: ServiceString;
+    targetCPUUtilizationPercentage: ServiceString;
+    targetRAMUtilizationPercentage: ServiceString;
 }
 const WorkerService = {
-    default: (name: string, startCommand: ServiceReadOnlyField): WorkerService => ({
+    default: (name: string, porterJson?: PorterJson): WorkerService => ({
         name,
-        cpu: '',
-        ram: '',
-        startCommand: startCommand,
+        cpu: ServiceField.string('100', porterJson?.apps?.[name]?.config?.resources?.requests?.cpu ? porterJson?.apps?.[name]?.config?.resources?.requests?.cpu.replace('m', '') : undefined),
+        ram: ServiceField.string('256', porterJson?.apps?.[name]?.config?.resources?.requests?.memory ? porterJson?.apps?.[name]?.config?.resources?.requests?.memory.replace('Mi', '') : undefined),
+        startCommand: ServiceField.string('', porterJson?.apps?.[name]?.run),
         type: 'worker',
-        replicas: '1',
-        autoscalingOn: false,
-        minReplicas: '1',
-        maxReplicas: '10',
-        targetCPUUtilizationPercentage: '50',
-        targetRAMUtilizationPercentage: '50',
+        replicas: ServiceField.string('1', porterJson?.apps?.[name]?.config?.replicaCount),
+        autoscalingOn: ServiceField.boolean(false, porterJson?.apps?.[name]?.config?.autoscaling?.enabled),
+        minReplicas: ServiceField.string('1', porterJson?.apps?.[name]?.config?.autoscaling?.minReplicas),
+        maxReplicas: ServiceField.string('10', porterJson?.apps?.[name]?.config?.autoscaling?.maxReplicas),
+        targetCPUUtilizationPercentage: ServiceField.string('50', porterJson?.apps?.[name]?.config?.autoscaling?.targetCPUUtilizationPercentage),
+        targetRAMUtilizationPercentage: ServiceField.string('50', porterJson?.apps?.[name]?.config?.autoscaling?.targetMemoryUtilizationPercentage),
+        canDelete: porterJson?.apps?.[name] == null,
     }),
+    serialize: (service: WorkerService) => {
+        const autoscaling = service.autoscalingOn.value ? {
+            autoscaling: {
+                enabled: true,
+                minReplicas: service.minReplicas.value,
+                maxReplicas: service.maxReplicas.value,
+                targetCPUUtilizationPercentage: service.targetCPUUtilizationPercentage.value,
+                targetMemoryUtilizationPercentage: service.targetRAMUtilizationPercentage.value,
+            }
+        } : {};
+        return {
+            replicaCount: service.replicas.value,
+            container: {
+                command: service.startCommand.value,
+            },
+            resources: {
+                requests: {
+                    cpu: service.cpu.value + 'm',
+                    memory: service.ram.value + 'Mi',
+                }
+            },
+            ...autoscaling,
+        }
+    },
+    deserialize: (name: string, values: any, porterJson?: PorterJson): WorkerService => {
+        return {
+            name,
+            cpu: ServiceField.string(values.resources?.requests?.cpu?.replace('m', ''), porterJson?.apps?.[name]?.config?.resources?.requests?.cpu ? porterJson?.apps?.[name]?.config?.resources?.requests?.cpu.replace('m', '') : undefined),
+            ram: ServiceField.string(values.resources?.requests?.memory?.replace('Mi', '') ?? '', porterJson?.apps?.[name]?.config?.resources?.requests?.memory ? porterJson?.apps?.[name]?.config?.resources?.requests?.memory.replace('Mi', '') : undefined),
+            startCommand: ServiceField.string(values.container?.command ?? '', porterJson?.apps?.[name]?.run),
+            type: 'worker',
+            replicas: ServiceField.string(values.replicaCount ?? '', porterJson?.apps?.[name]?.config?.replicaCount),
+            autoscalingOn: ServiceField.boolean(values.autoscaling?.enabled ?? false, porterJson?.apps?.[name]?.config?.autoscaling?.enabled),
+            minReplicas: ServiceField.string(values.autoscaling?.minReplicas ?? '', porterJson?.apps?.[name]?.config?.autoscaling?.minReplicas),
+            maxReplicas: ServiceField.string(values.autoscaling?.maxReplicas ?? '', porterJson?.apps?.[name]?.config?.autoscaling?.maxReplicas),
+            targetCPUUtilizationPercentage: ServiceField.string(values.autoscaling?.targetCPUUtilizationPercentage ?? '', porterJson?.apps?.[name]?.config?.autoscaling?.targetCPUUtilizationPercentage),
+            targetRAMUtilizationPercentage: ServiceField.string(values.autoscaling?.targetMemoryUtilizationPercentage ?? '', porterJson?.apps?.[name]?.config?.autoscaling?.targetMemoryUtilizationPercentage),
+            canDelete: porterJson?.apps?.[name] == null,
+        }
+    }
 }
 
 export type WebService = SharedServiceParams & Omit<WorkerService, 'type'> & {
     type: 'web';
-    port: string;
-    generateUrlForExternalTraffic: boolean;
-    customDomain?: string;
+    port: ServiceString;
+    generateUrlForExternalTraffic: ServiceBoolean;
+    customDomain: ServiceString;
 }
 const WebService = {
-    default: (name: string, startCommand: ServiceReadOnlyField): WebService => ({
+    default: (name: string, porterJson?: PorterJson): WebService => ({
         name,
-        cpu: '',
-        ram: '',
-        startCommand: startCommand,
+        cpu: ServiceField.string('100', porterJson?.apps?.[name]?.config?.resources?.requests?.cpu ? porterJson?.apps?.[name]?.config?.resources?.requests?.cpu.replace('m', '') : undefined),
+        ram: ServiceField.string('256', porterJson?.apps?.[name]?.config?.resources?.requests?.memory ? porterJson?.apps?.[name]?.config?.resources?.requests?.memory.replace('Mi', '') : undefined),
+        startCommand: ServiceField.string('', porterJson?.apps?.[name]?.run),
         type: 'web',
-        replicas: '1',
-        autoscalingOn: false,
-        minReplicas: '1',
-        maxReplicas: '10',
-        targetCPUUtilizationPercentage: '50',
-        targetRAMUtilizationPercentage: '50',
-        port: '80',
-        generateUrlForExternalTraffic: true,
+        replicas: ServiceField.string('1', porterJson?.apps?.[name]?.config?.replicaCount),
+        autoscalingOn: ServiceField.boolean(false, porterJson?.apps?.[name]?.config?.autoscaling?.enabled),
+        minReplicas: ServiceField.string('1', porterJson?.apps?.[name]?.config?.autoscaling?.minReplicas),
+        maxReplicas: ServiceField.string('10', porterJson?.apps?.[name]?.config?.autoscaling?.maxReplicas),
+        targetCPUUtilizationPercentage: ServiceField.string('50', porterJson?.apps?.[name]?.config?.autoscaling?.targetCPUUtilizationPercentage),
+        targetRAMUtilizationPercentage: ServiceField.string('50', porterJson?.apps?.[name]?.config?.autoscaling?.targetMemoryUtilizationPercentage),
+        port: ServiceField.string('8080', porterJson?.apps?.[name]?.config?.container?.port),
+        generateUrlForExternalTraffic: ServiceField.boolean(false, porterJson?.apps?.[name]?.config?.ingress?.enabled),
+        customDomain: ServiceField.string('', porterJson?.apps?.[name]?.config?.ingress?.hosts?.length ? porterJson?.apps?.[name]?.config?.ingress?.hosts[0] : undefined),
+        canDelete: porterJson?.apps?.[name] == null,
     }),
+    serialize: (service: WebService) => {
+        const autoscaling = service.autoscalingOn.value ? {
+            autoscaling: {
+                enabled: true,
+                minReplicas: service.minReplicas.value,
+                maxReplicas: service.maxReplicas.value,
+                targetCPUUtilizationPercentage: service.targetCPUUtilizationPercentage.value,
+                targetMemoryUtilizationPercentage: service.targetRAMUtilizationPercentage.value,
+            }
+        } : {};
+        return {
+            replicaCount: service.replicas.value,
+            resources: {
+                requests: {
+                    cpu: service.cpu.value + 'm',
+                    memory: service.ram.value + 'Mi',
+                }
+            },
+            container: {
+                command: service.startCommand.value,
+                port: service.port.value,
+            },
+            service: {
+                port: service.port.value,
+            },
+            ...autoscaling,
+        }
+    },
+    deserialize: (name: string, values: any, porterJson?: PorterJson): WebService => {
+        return {
+            name,
+            cpu: ServiceField.string(values.resources?.requests?.cpu?.replace('m', ''), porterJson?.apps?.[name]?.config?.resources?.requests?.cpu ? porterJson?.apps?.[name]?.config?.resources?.requests?.cpu.replace('m', '') : undefined),
+            ram: ServiceField.string(values.resources?.requests?.memory?.replace('Mi', '') ?? '', porterJson?.apps?.[name]?.config?.resources?.requests?.memory ? porterJson?.apps?.[name]?.config?.resources?.requests?.memory.replace('Mi', '') : undefined),
+            startCommand: ServiceField.string(values.container?.command ?? '', porterJson?.apps?.[name]?.run),
+            type: 'web',
+            replicas: ServiceField.string(values.replicaCount ?? '', porterJson?.apps?.[name]?.config?.replicaCount),
+            autoscalingOn: ServiceField.boolean(values.autoscaling?.enabled ?? false, porterJson?.apps?.[name]?.config?.autoscaling?.enabled),
+            minReplicas: ServiceField.string(values.autoscaling?.minReplicas ?? '', porterJson?.apps?.[name]?.config?.autoscaling?.minReplicas),
+            maxReplicas: ServiceField.string(values.autoscaling?.maxReplicas ?? '', porterJson?.apps?.[name]?.config?.autoscaling?.maxReplicas),
+            targetCPUUtilizationPercentage: ServiceField.string(values.autoscaling?.targetCPUUtilizationPercentage ?? '', porterJson?.apps?.[name]?.config?.autoscaling?.targetCPUUtilizationPercentage),
+            targetRAMUtilizationPercentage: ServiceField.string(values.autoscaling?.targetMemoryUtilizationPercentage ?? '', porterJson?.apps?.[name]?.config?.autoscaling?.targetMemoryUtilizationPercentage),
+            port: ServiceField.string(values.container?.port ?? '', porterJson?.apps?.[name]?.config?.container?.port),
+            generateUrlForExternalTraffic: ServiceField.boolean(values.ingress?.enabled ?? false, porterJson?.apps?.[name]?.config?.ingress?.enabled),
+            customDomain: ServiceField.string(values.ingress?.hosts?.length ? values.ingress.hosts[0] : '', porterJson?.apps?.[name]?.config?.ingress?.hosts?.length ? porterJson?.apps?.[name]?.config?.ingress?.hosts[0] : undefined),
+            canDelete: porterJson?.apps?.[name] == null,
+        }
+    }
 }
 
 export type JobService = SharedServiceParams & {
     type: 'job';
-    jobsExecuteConcurrently: boolean;
-    cronSchedule: string;
+    jobsExecuteConcurrently: ServiceBoolean;
+    cronSchedule: ServiceString;
 }
 const JobService = {
-    default: (name: string, startCommand: ServiceReadOnlyField): JobService => ({
+    default: (name: string, porterJson?: PorterJson): JobService => ({
         name,
-        cpu: '',
-        ram: '',
-        startCommand: startCommand,
+        cpu: ServiceField.string('100', porterJson?.apps?.[name]?.config?.resources?.requests?.cpu ? porterJson?.apps?.[name]?.config?.resources?.requests?.cpu.replace('m', '') : undefined),
+        ram: ServiceField.string('256', porterJson?.apps?.[name]?.config?.resources?.requests?.memory ? porterJson?.apps?.[name]?.config?.resources?.requests?.memory.replace('Mi', '') : undefined),
+        startCommand: ServiceField.string('', porterJson?.apps?.[name]?.run),
         type: 'job',
-        jobsExecuteConcurrently: false,
-        cronSchedule: '',
+        jobsExecuteConcurrently: ServiceField.boolean(false, porterJson?.apps?.[name]?.config?.allowConcurrent),
+        cronSchedule: ServiceField.string('', porterJson?.apps?.[name]?.config?.schedule?.value),
+        canDelete: porterJson?.apps?.[name] == null,
     }),
+    serialize: (service: JobService) => {
+        const schedule = service.cronSchedule.value ? {
+            schedule: {
+                enabled: true,
+                value: service.cronSchedule.value,
+            }
+        } : {};
+        return {
+            allowConcurrent: service.jobsExecuteConcurrently.value,
+            container: {
+                command: service.startCommand.value,
+            },
+            resources: {
+                requests: {
+                    cpu: service.cpu.value + 'm',
+                    memory: service.ram.value + 'Mi',
+                }
+            },
+            ...schedule,
+        }
+    },
+    deserialize: (name: string, values: any, porterJson?: PorterJson): JobService => {
+        return {
+            name,
+            cpu: ServiceField.string(values.resources?.requests?.cpu?.replace('m', ''), porterJson?.apps?.[name]?.config?.resources?.requests?.cpu ? porterJson?.apps?.[name]?.config?.resources?.requests?.cpu.replace('m', '') : undefined),
+            ram: ServiceField.string(values.resources?.requests?.memory?.replace('Mi', '') ?? '', porterJson?.apps?.[name]?.config?.resources?.requests?.memory ? porterJson?.apps?.[name]?.config?.resources?.requests?.memory.replace('Mi', '') : undefined),
+            startCommand: ServiceField.string(values.container?.command ?? '', porterJson?.apps?.[name]?.run),
+            type: 'job',
+            jobsExecuteConcurrently: ServiceField.boolean(values.allowConcurrent ?? false, porterJson?.apps?.[name]?.config?.allowConcurrent),
+            cronSchedule: ServiceField.string(values.schedule?.value ?? '', porterJson?.apps?.[name]?.config?.schedule?.value),
+            canDelete: porterJson?.apps?.[name] == null,
+        }
+    }
+}
+
+const TYPE_TO_SUFFIX: Record<ServiceType, string> = {
+    'web': '-web',
+    'worker': '-wkr',
+    'job': '-job',
+}
+const SUFFIX_TO_TYPE: Record<string, ServiceType> = {
+    '-web': 'web',
+    '-wkr': 'worker',
+    '-job': 'job',
+}
+
+export const Service = {
+    // populates an empty service
+    default: (name: string, type: ServiceType, porterJson?: PorterJson) => {
+        switch (type) {
+            case 'web':
+                return WebService.default(name, porterJson);
+            case 'worker':
+                return WorkerService.default(name, porterJson);
+            case 'job':
+                return JobService.default(name, porterJson);
+        }
+    },
+
+    // converts a service to a helm values object
+    serialize: (service: Service) => {
+        switch (service.type) {
+            case 'web':
+                return WebService.serialize(service);
+            case 'worker':
+                return WorkerService.serialize(service);
+            case 'job':
+                return JobService.serialize(service);
+        }
+    },
+
+    // converts a helm values object and porter json (from their repo) to a service
+    deserialize: (helmValues: any, defaultValues: any, porterJson?: PorterJson): Service[] => {
+        if (defaultValues == null) {
+            return [];
+        }
+
+        return Object.keys(defaultValues).map((name: string) => {
+            const suffix = name.slice(-4);
+            if (suffix in SUFFIX_TO_TYPE) {
+                const type = SUFFIX_TO_TYPE[suffix];
+                const appName = name.slice(0, -4);
+                const coalescedValues = overrideObjectValues(
+                    defaultValues[name],
+                    helmValues?.[name] ?? {}
+                );
+                switch (type) {
+                    case 'web':
+                        return WebService.deserialize(appName, coalescedValues, porterJson);
+                    case 'worker':
+                        return WorkerService.deserialize(appName, coalescedValues, porterJson);
+                    case 'job':
+                        return JobService.deserialize(appName, coalescedValues, porterJson);
+                }
+            }
+        }).filter((service: Service | undefined): service is Service => service != null);
+    },
+
+    // standard typeguards
+    isWeb: (service: Service): service is WebService => service.type === 'web',
+    isWorker: (service: Service): service is WorkerService => service.type === 'worker',
+    isJob: (service: Service): service is JobService => service.type === 'job',
+
+    // augments ingress of a web service, will be phased out
+    handleWebIngress: (service: WebService, stackName: string, projectId?: number, clusterId?: number) => {
+        if (projectId == null || clusterId == null) {
+            throw new Error('Project ID and Cluster ID must be provided to handle web ingress');
+        }
+        if (!service.generateUrlForExternalTraffic.value) {
+            return {}
+        }
+        const ingress: Ingress = {
+            ingress: {
+                enabled: true,
+                hosts: [],
+                custom_domain: false,
+                porter_hosts: [],
+            }
+        };
+        if (service.customDomain.value) {
+            ingress.ingress.hosts.push(service.customDomain.value);
+            ingress.ingress.custom_domain = true;
+        } else {
+            // const res = await api
+            //     .createSubdomain(
+            //         "<token>",
+            //         {},
+            //         {
+            //             id: projectId,
+            //             cluster_id: clusterId,
+            //             release_name: stackName,
+            //             namespace: `porter-stack-${stackName}`,
+            //         }
+            //     )
+            // if (res == null || res.data == null || res.data.external_url == null) {
+            //     throw new Error('Failed to create subdomain for web service');
+            // }
+            // ingress.porter_hosts.push(res.data.external_url)
+            //throw new Error('Generating external URLs without custom subdomains not yet supported!');
+        }
+
+        return ingress;
+    },
+
+    // required because of https://github.com/helm/helm/issues/9214
+    toHelmName: (service: Service): string => {
+        return service.name + TYPE_TO_SUFFIX[service.type]
+    },
+
+    retrieveEnvFromHelmValues: (helmValues: any): KeyValueType[] => {
+        const firstService = Object.keys(helmValues)[0];
+        const env = helmValues[firstService]?.container?.env?.normal;
+        if (env == null) {
+            return [];
+        }
+        try {
+            return Object.keys(env).map((key: string) => ({
+                key,
+                value: env[key],
+                hidden: false,
+                locked: false,
+                deleted: false,
+            }));
+        } catch (err) {
+            // TODO: handle error
+            return [];
+        }
+    },
+
+    retrieveSubdomainFromHelmValues: (services: Service[], helmValues: any): string => {
+        const webServices = services.filter(Service.isWeb);
+        if (webServices.length == 0) {
+            return "";
+        }
+
+        for (const web of webServices) {
+            const values = helmValues[Service.toHelmName(web)];
+            if (values == null || values.ingress == null || !values.ingress.enabled) {
+                continue;
+            }
+            if (values.ingress.custom_domain && values.ingress.hosts?.length > 0) {
+                return values.ingress.hosts[0];
+            }
+            if (values.ingress.porter_hosts?.length > 0) {
+                return values.ingress.porter_hosts[0];
+            }
+        }
+
+        return "";
+    }
 }
 
-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);
+type Ingress = {
+    ingress: {
+        enabled: boolean;
+        hosts: string[];
+        custom_domain: boolean;
+        porter_hosts: string[];
     }
 }

+ 43 - 0
dashboard/src/main/home/app-dashboard/new-app-flow/utils.ts

@@ -0,0 +1,43 @@
+export const overrideObjectValues = (obj1: any, obj2: any) => {
+  // Iterate over the keys in obj2
+  for (const key in obj2) {
+    // Check if the key exists in obj1 and if its value is an object
+    if (key in obj1 && obj1[key] !== null && typeof obj1[key] === 'object' && typeof obj2[key] === 'object') {
+      obj1[key] = overrideObjectValues(obj1[key], obj2[key]);
+    } else {
+      obj1[key] = obj2[key];
+    }
+  }
+
+  // Return the merged object
+  return obj1;
+};
+
+export const getGithubAction = (projectID?: number, clusterId?: number, stackName?: string, branchName?: string) => {
+  return `on:
+  push:
+    branches:
+    - ${branchName}
+name: Deploy to Porter
+jobs:
+  porter-deploy:
+    runs-on: ubuntu-latest
+    steps:
+    - name: Checkout code
+      uses: actions/checkout@v3
+    - name: Set Github tag
+      id: vars
+      run: echo "sha_short=$(git rev-parse --short HEAD)" >> $GITHUB_OUTPUT
+    - name: Deploy stack
+      timeout-minutes: 30
+      uses: porter-dev/porter-cli-action@v0.1.0
+      with:
+        command: apply -f porter.yaml
+      env:
+        PORTER_CLUSTER: ${clusterId}
+        PORTER_HOST: https://dashboard.getporter.dev
+        PORTER_PROJECT: ${projectID}
+        PORTER_STACK_NAME: ${stackName}
+        PORTER_TAG: \${{ steps.vars.outputs.sha_short }}
+        PORTER_TOKEN: \${{ secrets.PORTER_STACK_${projectID}_${clusterId} }}`;
+}

+ 43 - 41
dashboard/src/main/home/cluster-dashboard/dashboard/ProvisionerStatus.tsx

@@ -18,52 +18,54 @@ const PROVISIONING_STATUS_POLL_INTERVAL = 60 * 1000; // poll every minute
 
 const ProvisionerStatus: React.FC<Props> = ({ provisionFailureReason }) => {
   const { currentProject, currentCluster } = useContext(Context);
-  const [progress, setProgress] = useState(1);
+  const [progress, setProgress] = useState<number>(1);
 
   // Continuously poll provisioning status and cluster status
-  const pollProvisioningAndClusterStatus = async (currentProgress) => {
-    try {
-      if (currentProgress < 4) {
-        const resState = await api.getClusterState(
-          "<token>",
-          {},
-          {
-            project_id: currentProject.id,
-            cluster_id: currentCluster.id,
+  const pollProvisioningAndClusterStatus = async () => {
+    if (currentProject && currentCluster) {
+      try {
+        if (progress < 4) {
+          const resState = await api.getClusterState(
+            "<token>",
+            {},
+            {
+              project_id: currentProject.id,
+              cluster_id: currentCluster.id,
+            }
+          );
+          const {
+            is_control_plane_ready,
+            is_infrastructure_ready,
+            phase,
+          } = resState.data;
+          let newProgress = 1;
+          if (is_control_plane_ready) {
+            newProgress += 1;
           }
-        );
-        const {
-          is_control_plane_ready,
-          is_infrastructure_ready,
-          phase,
-        } = resState.data;
-        let newProgress = 1;
-        if (is_control_plane_ready) {
-          newProgress += 1;
-        }
-        if (is_infrastructure_ready) {
-          newProgress += 1;
-        }
-        if (phase === "Provisioned") {
-          newProgress += 1;
-        }
-        setProgress(newProgress);
-      } else {
-        const resStatus = await api.getCluster(
-          "<token>",
-          {},
-          {
-            project_id: currentProject.id,
-            cluster_id: currentCluster.id,
+          if (is_infrastructure_ready) {
+            newProgress += 1;
+          }
+          if (phase === "Provisioned") {
+            newProgress += 1;
+          }
+          setProgress(newProgress);
+        } else {
+          const resStatus = await api.getCluster(
+            "<token>",
+            {},
+            {
+              project_id: currentProject.id,
+              cluster_id: currentCluster.id,
+            }
+          );
+          const status = resStatus.data.status;
+          if (status === "READY") {
+            window.location.reload();
           }
-        );
-        const status = resStatus.data.status;
-        if (status === "READY") {
-          window.location.reload();
         }
+      } catch (err) {
+        console.log(err);
       }
-    } catch (err) {
-      console.log(err);
     }
   };
 
@@ -72,7 +74,7 @@ const ProvisionerStatus: React.FC<Props> = ({ provisionFailureReason }) => {
       pollProvisioningAndClusterStatus,
       PROVISIONING_STATUS_POLL_INTERVAL
     );
-    pollProvisioningAndClusterStatus(progress);
+    pollProvisioningAndClusterStatus();
     return () => clearInterval(intervalId);
   }, []);
 

+ 16 - 20
dashboard/src/main/home/cluster-dashboard/env-groups/EnvGroupArray.tsx

@@ -24,10 +24,6 @@ type PropsType = {
   secretOption?: boolean;
 };
 
-type StateType = {
-  showEditorModal: boolean;
-};
-
 const EnvGroupArray = ({
   label,
   values,
@@ -46,7 +42,7 @@ const EnvGroupArray = ({
 
   const readFile = (env: string) => {
     const envObj = dotenv_parse(env);
-    const _values = values;
+    const _values = [...values];
 
     for (const key in envObj) {
       let push = true;
@@ -92,7 +88,7 @@ const EnvGroupArray = ({
                     width="270px"
                     value={entry.key}
                     onChange={(e: any) => {
-                      let _values = values;
+                      const _values = [...values];
                       _values[i].key = e.target.value;
                       setValues(_values);
                     }}
@@ -107,7 +103,7 @@ const EnvGroupArray = ({
                       width="270px"
                       value={entry.value}
                       onChange={(e: any) => {
-                        let _values = values;
+                        const _values = [...values];
                         _values[i].value = e.target.value;
                         setValues(_values);
                       }}
@@ -121,7 +117,7 @@ const EnvGroupArray = ({
                       width="270px"
                       value={entry.value}
                       onChange={(e: any) => {
-                        let _values = values;
+                        const _values = [...values];
                         _values[i].value = e.target.value;
                         setValues(_values);
                       }}
@@ -130,12 +126,11 @@ const EnvGroupArray = ({
                       spellCheck={false}
                     />
                   )}
-
                   {secretOption && (
                     <HideButton
                       onClick={() => {
                         if (!entry.locked) {
-                          let _values = values;
+                          const _values = [...values];
                           _values[i].hidden = !_values[i].hidden;
                           setValues(_values);
                         }
@@ -167,14 +162,16 @@ const EnvGroupArray = ({
           <InputWrapper>
             <AddRowButton
               onClick={() => {
-                let _values = values;
-                _values.push({
-                  key: "",
-                  value: "",
-                  hidden: false,
-                  locked: false,
-                  deleted: false,
-                });
+                const _values = [
+                  ...values,
+                  {
+                    key: "",
+                    value: "",
+                    hidden: false,
+                    locked: false,
+                    deleted: false,
+                  },
+                ];
                 setValues(_values);
               }}
             >
@@ -197,7 +194,7 @@ const EnvGroupArray = ({
         <Modal
           onRequestClose={() => setShowEditorModal(false)}
           width="60%"
-          height="80%"
+          height="650px"
         >
           <EnvEditorModal
             closeModal={() => setShowEditorModal(false)}
@@ -210,7 +207,6 @@ const EnvGroupArray = ({
 };
 
 export default EnvGroupArray;
-
 const Spacer = styled.div`
   width: 10px;
   height: 20px;

+ 104 - 0
dashboard/src/main/home/cluster-dashboard/expanded-chart/DeploymentTypeStacks.tsx

@@ -0,0 +1,104 @@
+import React, { useState } from "react";
+import styled from "styled-components";
+
+import { integrationList } from "shared/common";
+import { ChartType } from "shared/types";
+
+type Props = {
+  appData: any;
+};
+
+const DeploymentTypeStacks: React.FC<Props> = ({ appData }) => {
+  const [showRepoTooltip, setShowRepoTooltip] = useState(false);
+
+  const githubRepository = appData?.app.repo_name;
+  const icon = githubRepository
+    ? integrationList.repo.icon
+    : integrationList.registry.icon;
+
+  const repository =
+    githubRepository ||
+    appData.cluster?.image_repo_uri ||
+    appData.cluster?.config?.image?.repository;
+
+  if (repository?.includes("hello-porter")) {
+    return null;
+  }
+
+  return (
+    <DeploymentImageContainer>
+      <DeploymentTypeIcon src={icon} />
+      <RepositoryName
+        onMouseOver={() => {
+          setShowRepoTooltip(true);
+        }}
+        onMouseOut={() => {
+          setShowRepoTooltip(false);
+        }}
+      >
+        {repository}
+      </RepositoryName>
+      {showRepoTooltip && <Tooltip>{repository}</Tooltip>}
+    </DeploymentImageContainer>
+  );
+};
+
+export default DeploymentTypeStacks;
+
+const DeploymentImageContainer = styled.div`
+  height: 20px;
+  font-size: 13px;
+  position: relative;
+  display: flex;
+  margin-left: 15px;
+  margin-bottom: -3px;
+  align-items: center;
+  font-weight: 400;
+  justify-content: center;
+  color: #ffffff66;
+  padding-left: 5px;
+`;
+
+const Icon = styled.img`
+  width: 100%;
+`;
+
+const DeploymentTypeIcon = styled(Icon)`
+  width: 20px;
+  margin-right: 10px;
+`;
+
+const RepositoryName = styled.div`
+  white-space: nowrap;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  max-width: 390px;
+  position: relative;
+  margin-right: 3px;
+`;
+
+const Tooltip = styled.div`
+  position: absolute;
+  left: -40px;
+  top: 28px;
+  min-height: 18px;
+  max-width: calc(700px);
+  padding: 5px 7px;
+  background: #272731;
+  z-index: 999;
+  color: white;
+  font-size: 12px;
+  font-family: "Work Sans", sans-serif;
+  outline: 1px solid #ffffff55;
+  opacity: 0;
+  animation: faded-in 0.2s 0.15s;
+  animation-fill-mode: forwards;
+  @keyframes faded-in {
+    from {
+      opacity: 0;
+    }
+    to {
+      opacity: 1;
+    }
+  }
+`;

+ 5 - 1
dashboard/src/main/home/cluster-dashboard/expanded-chart/ExpandedChart.tsx

@@ -1157,7 +1157,11 @@ const TabButton = styled.div`
   position: absolute;
   right: 0px;
   height: 30px;
-  background: linear-gradient(to right, #00000000, ${props => props.theme.bg} 20%);
+  background: linear-gradient(
+    to right,
+    #00000000,
+    ${(props) => props.theme.bg} 20%
+  );
   padding-left: 30px;
   display: flex;
   align-items: center;

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

@@ -7,7 +7,7 @@ import api from "shared/api";
 import { Context } from "shared/Context";
 import { ChartType } from "shared/types";
 import Loading from "components/Loading";
-import Banner from "components/Banner";
+import Banner from "components/porter/Banner";
 import styled from "styled-components";
 
 const NOTIF_CATEGORIES = ["success", "fail"];

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

@@ -476,9 +476,9 @@ const RevisionHeader = styled.div`
   width: 100%;
   padding-left: 10px;
   cursor: pointer;
-  background: #26292e;
+  background: ${({ theme }) => theme.fg};
   :hover {
-    background: ${(props) => props.showRevisions && "#ffffff18"};
+    background: ${(props) => props.showRevisions && props.theme.fg2};
   }
 
   > div > i {

+ 1 - 1
dashboard/src/main/home/cluster-dashboard/expanded-chart/build-settings/BuildSettingsTab.tsx

@@ -15,7 +15,7 @@ import styled from "styled-components";
 import yaml from "js-yaml";
 import { AxiosError } from "axios";
 import BranchList from "components/repo-selector/BranchList";
-import Banner from "components/Banner";
+import Banner from "components/porter/Banner";
 import { UpdateBuildconfigResponse } from "./types";
 import BuildpackConfigSection from "./_BuildpackConfigSection";
 import InputRow from "components/form-components/InputRow";

+ 1 - 1
dashboard/src/main/home/cluster-dashboard/expanded-chart/logs-section/LogsSection.tsx

@@ -20,7 +20,7 @@ import dayjs from "dayjs";
 import Loading from "components/Loading";
 import _ from "lodash";
 import { ChartType } from "shared/types";
-import Banner from "components/Banner";
+import Banner from "components/porter/Banner";
 
 export type InitLogData = Partial<{
   podName: string;

+ 1 - 1
dashboard/src/main/home/cluster-dashboard/preview-environments/components/PreviewEnvironmentsHeader.tsx

@@ -3,7 +3,7 @@ import styled from "styled-components";
 import DashboardHeader from "../../DashboardHeader";
 import PullRequestIcon from "assets/pull_request_icon.svg";
 import api from "shared/api";
-import Banner from "components/Banner";
+import Banner from "components/porter/Banner";
 import Spacer from "components/porter/Spacer";
 
 export const PreviewEnvironmentsHeader = () => {

+ 1 - 1
dashboard/src/main/home/cluster-dashboard/preview-environments/deployments/DeploymentDetail.tsx

@@ -13,7 +13,7 @@ import ChartList from "../../chart/ChartList";
 import github from "assets/github-white.png";
 import { integrationList } from "shared/common";
 import { capitalize } from "shared/string_utils";
-import Banner from "components/Banner";
+import Banner from "components/porter/Banner";
 import Modal from "main/home/modals/Modal";
 import { validatePorterYAML } from "../utils";
 import Placeholder from "components/Placeholder";

+ 1 - 1
dashboard/src/main/home/cluster-dashboard/preview-environments/deployments/DeploymentList.tsx

@@ -13,7 +13,7 @@ import DynamicLink from "components/DynamicLink";
 import DashboardHeader from "../../DashboardHeader";
 import RadioFilter from "components/RadioFilter";
 import Placeholder from "components/Placeholder";
-import Banner from "components/Banner";
+import Banner from "components/porter/Banner";
 
 import pullRequestIcon from "assets/pull_request_icon.svg";
 import filterOutline from "assets/filter-outline.svg";

+ 1 - 1
dashboard/src/main/home/cluster-dashboard/preview-environments/environments/CreateBranchEnvironment.tsx

@@ -6,7 +6,7 @@ import Helper from "components/form-components/Helper";
 import api from "shared/api";
 import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
 import { validatePorterYAML } from "../utils";
-import Banner from "components/Banner";
+import Banner from "components/porter/Banner";
 import { useRouting } from "shared/routing";
 import PorterYAMLErrorsModal from "../components/PorterYAMLErrorsModal";
 import Placeholder from "components/Placeholder";

+ 1 - 1
dashboard/src/main/home/cluster-dashboard/preview-environments/environments/CreatePREnvironment.tsx

@@ -8,7 +8,7 @@ import api from "shared/api";
 import { EllipsisTextWrapper } from "../components/styled";
 import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
 import { getPRDeploymentList, validatePorterYAML } from "../utils";
-import Banner from "components/Banner";
+import Banner from "components/porter/Banner";
 import { useRouting } from "shared/routing";
 import PorterYAMLErrorsModal from "../components/PorterYAMLErrorsModal";
 import Placeholder from "components/Placeholder";

+ 1 - 1
dashboard/src/main/home/cluster-dashboard/preview-environments/environments/EnvironmentSettings.tsx

@@ -14,7 +14,7 @@ import SaveButton from "components/SaveButton";
 import _ from "lodash";
 import { Context } from "shared/Context";
 import PageNotFound from "components/PageNotFound";
-import Banner from "components/Banner";
+import Banner from "components/porter/Banner";
 import InputRow from "components/form-components/InputRow";
 import Modal from "main/home/modals/Modal";
 import { useRouting } from "shared/routing";

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

@@ -3,7 +3,7 @@ import styled from "styled-components";
 
 import { Context } from "shared/Context";
 
-import Banner from "components/Banner";
+import Banner from "components/porter/Banner";
 
 import ProvisionerFlow from "components/ProvisionerFlow";
 import ClusterList from "./ClusterList";

+ 144 - 230
dashboard/src/main/home/dashboard/Dashboard.tsx

@@ -1,12 +1,14 @@
-import React, { Component } from "react";
+import React, { useState, useContext, useEffect } from "react";
 import styled from "styled-components";
+import { RouteComponentProps, withRouter } from "react-router";
 
 import gradient from "assets/gradient.png";
+
 import { Context } from "shared/Context";
 import { InfraType } from "shared/types";
 import api from "shared/api";
-
-import { RouteComponentProps, withRouter } from "react-router";
+import { pushFiltered, pushQueryParams } from "shared/routing";
+import { withAuth, WithAuthProps } from "shared/auth/AuthorizationHoc";
 
 import ProvisionerSettings from "../provisioner/ProvisionerSettings";
 import ClusterPlaceholderContainer from "./ClusterPlaceholderContainer";
@@ -15,113 +17,114 @@ import FormDebugger from "components/porter-form/FormDebugger";
 import TitleSection from "components/TitleSection";
 import ClusterSection from "./ClusterSection";
 import { StatusPage } from "../onboarding/steps/ProvisionResources/forms/StatusPage";
-import Banner from "components/Banner";
-
-import { pushFiltered, pushQueryParams } from "shared/routing";
-import { withAuth, WithAuthProps } from "shared/auth/AuthorizationHoc";
+import Banner from "components/porter/Banner";
 import Spacer from "components/porter/Spacer";
 
-type PropsType = RouteComponentProps &
-  WithAuthProps & {
-    projectId: number | null;
-    setRefreshClusters: (x: boolean) => void;
-  };
-
-type StateType = {
-  infras: InfraType[];
-  pressingCtrl: boolean;
-  pressingK: boolean;
-  showFormDebugger: boolean;
+type Props = RouteComponentProps & WithAuthProps & {
+  projectId: number | null;
+  setRefreshClusters: (x: boolean) => void;
 };
 
-class Dashboard extends Component<PropsType, StateType> {
-  state = {
-    infras: [] as InfraType[],
-    pressingCtrl: false,
-    pressingK: false,
-    showFormDebugger: false,
+const Dashboard: React.FC<Props> = ({
+  projectId,
+  setRefreshClusters,
+  ...props
+}) => {
+  const { currentProject, user, capabilities } = useContext(Context);
+  const [infras, setInfras] = useState<InfraType[]>([]);
+  const [pressingCtrl, setPressingCtrl] = useState(false);
+  const [pressingK, setPressingK] = useState(false);
+  const [showFormDebugger, setShowFormDebugger] = useState(false);
+  const [tabOptions, setTabOptions] = useState([{ 
+    label: "Connected clusters",
+    value: "overview"
+  }]);
+
+  const handleKeyDown = (e: KeyboardEvent): void => {
+    if (e.key === "k") {
+      setPressingK(true);
+    }
+    if (e.key === "Meta" || e.key === "Control") {
+      setPressingCtrl(true);
+    }
+    if (e.key === "z" && pressingK && pressingCtrl) {
+      setPressingK(false);
+      setPressingCtrl(false);
+      setShowFormDebugger(!showFormDebugger);
+    }
+  };
+
+  const handleKeyUp = (e: KeyboardEvent): void => {
+    if (e.key === "Meta" || e.key === "Control" || e.key === "k") {
+      setPressingK(false);
+      setPressingCtrl(false);
+    }
   };
 
-  refreshInfras = () => {
-    if (this.props.projectId) {
+  useEffect(() => {
+    document.addEventListener("keydown", handleKeyDown);
+    document.addEventListener("keyup", handleKeyUp);
+    return () => {
+      document.removeEventListener("keydown", handleKeyDown);
+      document.removeEventListener("keyup", handleKeyUp);
+    };
+  }, []);
+
+  useEffect(() => {
+    if (currentProject) {
+      if (currentProject.simplified_view_enabled) {
+        pushFiltered(props, "/apps", ["project_id"]);
+      }
       api
         .getInfra(
           "<token>",
           {},
           {
-            project_id: this.props.projectId,
+            project_id: currentProject.id,
           }
         )
-        .then((res) => this.setState({ infras: res.data }))
+        .then((res) => setInfras(res.data))
         .catch(console.log);
     }
-  };
-
-  componentDidMount() {
-    this.refreshInfras();
-    document.addEventListener("keydown", this.handleKeyDown);
-    document.addEventListener("keyup", this.handleKeyUp);
-  }
+  }, [currentProject]);
 
-  componentWillUnmount() {
-    document.removeEventListener("keydown", this.handleKeyDown);
-    document.removeEventListener("keyup", this.handleKeyUp);
-  }
+  const currentTab = () => new URLSearchParams(props.location.search).get("tab");
 
-  handleKeyDown = (e: KeyboardEvent): void => {
-    let { pressingK, pressingCtrl } = this.state;
-    if (e.key === "Meta" || e.key === "Control") {
-      this.setState({ pressingCtrl: true });
-    }
-    if (e.key === "k") {
-      this.setState({ pressingK: true });
-    }
-    if (e.key === "z" && pressingK && pressingCtrl) {
-      this.setState({ pressingK: false, pressingCtrl: false });
-      this.setState({ showFormDebugger: !this.state.showFormDebugger });
+  useEffect(() => {
+    if (props.isAuthorized("cluster", "", ["get", "create"])) {
+      tabOptions.push({ label: "Create a cluster", value: "create-cluster" });
     }
-  };
 
-  handleKeyUp = (e: KeyboardEvent): void => {
-    if (e.key === "Meta" || e.key === "Control" || e.key === "k") {
-      this.setState({ pressingCtrl: false, pressingK: false });
-    }
-  };
+    tabOptions.push({ label: "Provisioner status", value: "provisioner" });
 
-  componentDidUpdate(prevProps: PropsType) {
-    if (this.props.projectId && prevProps.projectId !== this.props.projectId) {
-      this.refreshInfras();
+    if (!capabilities?.provisioner) {
+      let newTabs = [{ label: "Project overview", value: "overview" }];
+      setTabOptions(newTabs);
+    } else {
+      setTabOptions(tabOptions);
     }
-  }
+  }, [currentProject]);
 
-  currentTab = () => new URLSearchParams(this.props.location.search).get("tab");
-
-  renderTabContents = () => {
-    if (this.currentTab() === "provisioner") {
+  const renderTabContents = () => {
+    if (currentTab() === "provisioner") {
       return (
         <StatusPage
           filter={[]}
-          project_id={this.props.projectId}
+          project_id={currentProject.id}
           setInfraStatus={() => null}
         />
       );
-    } else if (this.currentTab() === "create-cluster") {
-      let helperText = "Create a cluster to link to this project";
-      let helperType = "info";
-      if (
-        true
-      ) {
-        helperText =
-          "You need to update your billing to provision or connect a new cluster";
-        helperType = "warning";
-      }
+    } else if (currentTab() === "create-cluster") {
+      const helperText =
+        "You need to update your billing to provision or connect a new cluster";
+      const helperType = "warning";
       return (
         <>
           <Banner type={helperType} noMargin>
             {helperText}
           </Banner>
           <Br />
-          <ProvisionerSettings infras={this.state.infras} provisioner={true} />
+          <ProvisionerSettings infras={infras} provisioner={true} />
         </>
       );
     } else {
@@ -129,139 +132,74 @@ class Dashboard extends Component<PropsType, StateType> {
     }
   };
 
-  onShowProjectSettings = () => {
-    pushFiltered(this.props, "/project-settings", ["project_id"]);
-  };
-
-  setCurrentTab = (x: string) => pushQueryParams(this.props, { tab: x });
-
-  render() {
-    let { currentProject, capabilities } = this.context;
-    let { onShowProjectSettings } = this;
-
-    let tabOptions = [{ label: "Connected clusters", value: "overview" }];
-
-    if (this.props.isAuthorized("cluster", "", ["get", "create"])) {
-      tabOptions.push({ label: "Create a cluster", value: "create-cluster" });
-    }
-
-    tabOptions.push({ label: "Provisioner status", value: "provisioner" });
-
-    if (!capabilities?.provisioner) {
-      tabOptions = [{ label: "Project overview", value: "overview" }];
-    }
-
-    return (
-      <>
-        {currentProject && (
-          <DashboardWrapper>
-            {this.state.showFormDebugger ? (
-              <FormDebugger
-                goBack={() => this.setState({ showFormDebugger: false })}
-              />
-            ) : (
-              <>
-                <TitleSection>
-                  <DashboardIcon>
-                    <DashboardImage src={gradient} />
-                    <Overlay>
-                      {currentProject && currentProject.name[0].toUpperCase()}
-                    </Overlay>
-                  </DashboardIcon>
-                  {currentProject && currentProject.name}
-                  {this.context.currentProject?.roles?.filter((obj: any) => {
-                    return obj.user_id === this.context.user.userId;
-                  })[0].kind === "admin" || (
-                    <i
-                      className="material-icons"
-                      onClick={onShowProjectSettings}
-                    >
-                      more_vert
-                    </i>
-                  )}
-                </TitleSection>
-                <Spacer height="15px" />
-
-                <InfoSection>
-                  <TopRow>
-                    <InfoLabel>
-                      <i className="material-icons">info</i> Info
-                    </InfoLabel>
-                  </TopRow>
-                  <Description>
-                    Project overview for {currentProject && currentProject.name}
-                    .
-                  </Description>
-                </InfoSection>
-                {
-                  currentProject?.capi_provisioner_enabled ? (
-                    <ClusterSection />
-                  ) : (
-                    <TabRegion
-                      currentTab={this.currentTab()}
-                      setCurrentTab={this.setCurrentTab}
-                      options={tabOptions}
-                    >
-                      {this.renderTabContents()}
-                    </TabRegion>
-                  )
-                }
-              </>
-            )}
-          </DashboardWrapper>
-        )}
-      </>
-    );
-  }
-}
-
-Dashboard.contextType = Context;
+  return (
+    <>
+      {currentProject && (
+        <DashboardWrapper>
+          {showFormDebugger ? (
+            <FormDebugger
+              goBack={() => setShowFormDebugger(false)}
+            />
+          ) : (
+            <>
+              <TitleSection>
+                <DashboardIcon>
+                  <DashboardImage src={gradient} />
+                  <Overlay>
+                    {currentProject && currentProject.name[0].toUpperCase()}
+                  </Overlay>
+                </DashboardIcon>
+                {currentProject && currentProject.name}
+                {currentProject?.roles?.filter((obj: any) => {
+                  return obj.user_id === user.userId;
+                })[0].kind === "admin" || (
+                  <i
+                    className="material-icons"
+                    onClick={() => {
+                      pushFiltered(props, "/project-settings", ["project_id"]);
+                    }}
+                  >
+                    more_vert
+                  </i>
+                )}
+              </TitleSection>
+              <Spacer height="15px" />
+              <InfoSection>
+                <TopRow>
+                  <InfoLabel>
+                    <i className="material-icons">info</i> Info
+                  </InfoLabel>
+                </TopRow>
+                <Description>
+                  Project overview for {currentProject && currentProject.name}
+                  .
+                </Description>
+              </InfoSection>
+              {
+                currentProject?.capi_provisioner_enabled ? (
+                  <ClusterSection />
+                ) : (
+                  <TabRegion
+                    currentTab={currentTab()}
+                    setCurrentTab={(x: string) => {
+                      pushQueryParams(props, { tab: x });
+                    }}
+                    options={tabOptions}
+                  >
+                    {renderTabContents()}
+                  </TabRegion>
+                )
+              }
+            </>
+          )}
+        </DashboardWrapper>
+      )}
+    </>
+  );
+};
 
 export default withRouter(withAuth(Dashboard));
 
-const Button = styled.div`
-  display: flex;
-  flex-direction: row;
-  align-items: center;
-  justify-content: space-between;
-  font-size: 13px;
-  cursor: pointer;
-  font-family: "Work Sans", sans-serif;
-  border-radius: 5px;
-  font-weight: 500;
-  width: 147px;
-  margin-bottom: 30px;
-  color: white;
-  height: 30px;
-  padding: 0 8px;
-  padding-right: 13px;
-  overflow: hidden;
-  white-space: nowrap;
-  text-overflow: ellipsis;
-  cursor: ${(props: { disabled?: boolean }) =>
-    props.disabled ? "not-allowed" : "pointer"};
-
-  background: ${(props: { disabled?: boolean }) =>
-    props.disabled ? "#aaaabbee" : "#616FEEcc"};
-  :hover {
-    background: ${(props: { disabled?: boolean }) =>
-      props.disabled ? "" : "#505edddd"};
-  }
-
-  > i {
-    color: white;
-    width: 18px;
-    height: 18px;
-    font-weight: 600;
-    font-size: 12px;
-    border-radius: 20px;
-    display: flex;
-    align-items: center;
-    margin-right: 5px;
-    justify-content: center;
-  }
-`;
-
 const Br = styled.div`
   width: 100%;
   height: 1px;
@@ -271,23 +209,6 @@ const DashboardWrapper = styled.div`
   padding-bottom: 100px;
 `;
 
-// const Banner = styled.div<{ color: string }>`
-//   height: 40px;
-//   width: 100%;
-//   margin: 5px 0 30px;
-//   font-size: 13px;
-//   display: flex;
-//   border-radius: 5px;
-//   padding-left: 15px;
-//   align-items: center;
-//   background: #ffffff11;
-//   color: ${(props) => props.color};
-//   > i {
-//     margin-right: 10px;
-//     font-size: 18px;
-//   }
-// `;
-
 const TopRow = styled.div`
   display: flex;
   align-items: center;
@@ -321,13 +242,6 @@ const InfoSection = styled.div`
   margin-bottom: 30px;
 `;
 
-const LineBreak = styled.div`
-  width: calc(100% - 0px);
-  height: 1px;
-  background: #494b4f;
-  margin: 10px 0px 20px;
-`;
-
 const Overlay = styled.div`
   height: 100%;
   width: 100%;

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

@@ -259,7 +259,7 @@ const Placeholder = styled.div`
   justify-content: center;
   color: #aaaabb;
   border-radius: 5px;
-  background: #26292e;
+  background: ${({ theme }) => theme.fg};
   border: 1px solid #494b4f;
 `;
 

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

@@ -51,7 +51,7 @@ const Integrations: React.FC<PropsType> = (props) => {
                 >
                   {integrationList[integration].label}
                 </TitleSection>
-                <Buffer />
+                <Spacer y={1} />
                 <CreateIntegrationForm
                   integrationName={integration}
                   closeForm={() => {

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

@@ -107,7 +107,7 @@ const Placeholder = styled.div`
   justify-content: center;
   color: #aaaabb;
   border-radius: 5px;
-  background: #26292e;
+  background: ${({ theme }) => theme.fg};
   border: 1px solid #494b4f;
 `;
 

+ 1 - 1
dashboard/src/main/home/integrations/create-integration/ECRForm.tsx

@@ -132,7 +132,7 @@ ECRForm.contextType = Context;
 const CredentialWrapper = styled.div`
   padding: 30px;
   border-radius: 5px;
-  background: #26292e;
+  background: ${({ theme }) => theme.fg}};
   border: 1px solid #494b4f;
   margin-bottom: 30px;
 `;

+ 15 - 50
dashboard/src/main/home/modals/EnvEditorModal.tsx

@@ -9,6 +9,9 @@ import "ace-builds/src-noconflict/mode-text";
 import { Context } from "shared/Context";
 
 import SaveButton from "components/SaveButton";
+import Text from "components/porter/Text";
+import Spacer from "components/porter/Spacer";
+import Button from "components/porter/Button";
 
 type PropsType = {
   closeModal: () => void;
@@ -39,13 +42,14 @@ export default class EnvEditorModal extends Component<PropsType, StateType> {
     this.setState({ envFile: e });
   };
 
-  componentDidMount() {}
-
   render() {
     return (
       <StyledLoadEnvGroupModal>
-        <ModalTitle>Load from Environment Group</ModalTitle>
-        <Subtitle>Copy paste your environment file in .env format:</Subtitle>
+        <Text size={16}>Load from Environment Group</Text>
+        <Spacer y={0.5} />
+        <Text color="helper">
+          Copy paste your environment file in .env format:
+        </Text>
 
         <Editor
           onSubmit={(e: any) => {
@@ -70,17 +74,17 @@ export default class EnvEditorModal extends Component<PropsType, StateType> {
             fontSize={14}
           />
         </Editor>
-
-        <SaveButton
+        <Button
           disabled={this.state.envFile == ""}
-          text="Submit"
           status={
             this.state.envFile == ""
               ? "No env file detected"
               : "Existing env variables will be overidden"
           }
           onClick={this.onSubmit}
-        />
+        >
+          Submit
+        </Button>
       </StyledLoadEnvGroupModal>
     );
   }
@@ -90,10 +94,11 @@ EnvEditorModal.contextType = Context;
 
 const Editor = styled.form`
   margin-top: 20px;
+  margin-bottom: 20px;
   border-radius: ${(props: { border: boolean }) => (props.border ? "5px" : "")};
   border: ${(props: { border: boolean }) =>
     props.border ? "1px solid #ffffff22" : ""};
-  height: 80%;
+  height: calc(100% - 135px);
   font-family: monospace !important;
   .ace_scrollbar {
     display: none;
@@ -108,45 +113,6 @@ const Editor = styled.form`
   }
 `;
 
-const Subtitle = styled.div`
-  margin-top: 15px;
-  font-family: "Work Sans", sans-serif;
-  font-size: 13px;
-  color: #aaaabb;
-`;
-
-const ModalTitle = styled.div`
-  margin: 0px 0px 13px;
-  display: flex;
-  flex: 1;
-  font-family: Work Sans, sans-serif;
-  font-size: 18px;
-  color: #ffffff;
-  user-select: none;
-  font-weight: 700;
-  align-items: center;
-  position: relative;
-  white-space: nowrap;
-  text-overflow: ellipsis;
-`;
-
-const CloseButton = styled.div`
-  position: absolute;
-  display: block;
-  width: 40px;
-  height: 40px;
-  padding: 13px 0 12px 0;
-  z-index: 1;
-  text-align: center;
-  border-radius: 50%;
-  right: 15px;
-  top: 12px;
-  cursor: pointer;
-  :hover {
-    background-color: #ffffff11;
-  }
-`;
-
 const CloseButtonImg = styled.img`
   width: 14px;
   margin: 0 auto;
@@ -160,6 +126,5 @@ const StyledLoadEnvGroupModal = styled.div`
   height: 100%;
   padding: 25px 30px;
   overflow: hidden;
-  border-radius: 6px;
-  background: #202227;
+  border-radius: 10px;
 `;

+ 1 - 1
dashboard/src/main/home/modals/UsageWarningModal.tsx

@@ -1,7 +1,7 @@
 import React, { useContext, useEffect, useState } from "react";
 import styled from "styled-components";
 
-import Banner from "components/Banner";
+import Banner from "components/porter/Banner";
 
 import { Context } from "shared/Context";
 import { Usage, UsageData } from "shared/types";

+ 1 - 1
dashboard/src/main/home/onboarding/Onboarding.tsx

@@ -159,7 +159,7 @@ const Onboarding = () => {
           <DashboardHeader
             image={lightning}
             title="Getting started"
-            description="Create a new cluster in your own cloud provider to get started with Porter."
+            description="Select your existing cloud provider to get started with Porter."
             disableLineBreak
             capitalize={false}
           />

+ 3 - 0
dashboard/src/main/home/sidebar/Clusters.tsx

@@ -39,6 +39,9 @@ class Clusters extends Component<PropsType, StateType> {
   };
 
   updateClusters = () => {
+    if (!this.context.currentProject) {
+      return
+    }
     let {
       user,
       currentProject,

+ 18 - 0
dashboard/src/main/home/sidebar/Sidebar.tsx

@@ -1,11 +1,13 @@
 import React, { Component } from "react";
 import styled from "styled-components";
+
 import category from "assets/category.svg";
 import integrations from "assets/integrations-bold.png";
 import rocket from "assets/rocket.png";
 import settings from "assets/settings-bold.png";
 import web from "assets/web-bold.png";
 import addOns from "assets/add-ons-bold.png";
+import infra from "assets/infra.png";
 
 import { Context } from "shared/Context";
 
@@ -194,6 +196,22 @@ class Sidebar extends Component<PropsType, StateType> {
               Integrations
             </NavButton>
           )}
+          {this.props.isAuthorized("settings", "", [
+            "get",
+            "update",
+            "delete",
+          ]) && (
+            <NavButton
+              path={"/cluster-dashboard"}
+              targetClusterName={currentCluster?.name}
+              active={
+                window.location.pathname.startsWith("/cluster-dashboard")
+              }
+            >
+              <Img src={infra} />
+              Infrastructure
+            </NavButton>
+          )}
           {this.props.isAuthorized("settings", "", [
             "get",
             "update",

+ 90 - 33
dashboard/src/shared/api.tsx

@@ -164,6 +164,17 @@ const createEmailVerification = baseApi<{}, {}>("POST", (pathParams) => {
   return `/api/email/verify/initiate`;
 });
 
+const getPorterApps = baseApi<
+  {},
+  {
+    project_id: number;
+    cluster_id: number;
+  }
+>("GET", (pathParams) => {
+  let { project_id, cluster_id } = pathParams;
+  return `/api/projects/${project_id}/clusters/${cluster_id}/stacks`;
+});
+
 const getPorterApp = baseApi<
   {},
   {
@@ -181,10 +192,12 @@ const createPorterApp = baseApi<
     name: string;
     repo_name: string;
     git_branch: string;
+    git_repo_id: number;
     build_context: string;
     builder: string;
     buildpacks: string;
     dockerfile: string;
+    image_repo_uri: string;
   },
   {
     project_id: number;
@@ -195,16 +208,47 @@ const createPorterApp = baseApi<
   return `/api/projects/${project_id}/clusters/${cluster_id}/stacks/update_config`;
 });
 
-const updatePorterStack = baseApi<
+const updatePorterApp = baseApi<
+  {
+    repo_name?: string;
+    git_branch?: string;
+    build_context?: string;
+    builder?: string;
+    buildpacks?: string;
+    dockerfile?: string;
+    image_repo_uri?: string;
+    pull_request_url?: string;
+  },
+  {
+    project_id: number;
+    cluster_id: number;
+    name: string;
+  }
+>("POST", (pathParams) => {
+  let { project_id, cluster_id, name } = pathParams;
+  return `/api/projects/${project_id}/clusters/${cluster_id}/stacks/${name}`;
+});
+
+const deletePorterApp = baseApi<
+  {},
+  {
+    project_id: number;
+    cluster_id: number;
+    name: string;
+  }
+>("DELETE", (pathParams) => {
+  let { project_id, cluster_id, name } = pathParams;
+  return `/api/projects/${project_id}/clusters/${cluster_id}/stacks/${name}`;
+});
+
+const createPorterStack = baseApi<
   {
     stack_name: string;
-    dependencies: {
-      name: string;
-      alias: string;
-      version: string;
+    porter_yaml: string;
+    image_info?: {
       repository: string;
-    }[];
-    values: any;
+      tag: string;
+    }
   },
   {
     project_id: number;
@@ -215,6 +259,25 @@ const updatePorterStack = baseApi<
   return `/api/projects/${project_id}/clusters/${cluster_id}/stacks`;
 });
 
+const updatePorterStack = baseApi<
+  {
+    stack_name: string;
+    porter_yaml: string;
+    image_info?: {
+      repository: string;
+      tag: string;
+    }
+  },
+  {
+    project_id: number;
+    cluster_id: number;
+    stack_name: string;
+  }
+>("PATCH", (pathParams) => {
+  let { project_id, cluster_id, stack_name } = pathParams;
+  return `/api/projects/${project_id}/clusters/${cluster_id}/stacks/${stack_name}`;
+});
+
 const createEnvironment = baseApi<
   {
     name: string;
@@ -637,11 +700,9 @@ 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<
@@ -672,11 +733,9 @@ 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<
@@ -692,11 +751,9 @@ 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<
@@ -712,11 +769,9 @@ const getPorterYamlContents = 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)}/porteryaml`;
+  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<
@@ -1570,11 +1625,9 @@ 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<
@@ -2487,7 +2540,7 @@ 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<
   {
@@ -2541,8 +2594,12 @@ export default {
   createPasswordResetVerify,
   createPasswordResetFinalize,
   createProject,
+  getPorterApps,
   getPorterApp,
   createPorterApp,
+  updatePorterApp,
+  deletePorterApp,
+  createPorterStack,
   updatePorterStack,
   createConfigMap,
   deleteCluster,

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

@@ -1,6 +1,7 @@
 const theme = {
   bg: "#121212",
   fg: "#171B21",
+  fg2: "#1E232B",
   border: "#494b4f",
   button: "#3A48CA",
   clickable: {

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

@@ -1,6 +1,7 @@
 const theme = {
   bg: "#202227",
   fg: "#27292e",
+  fg2: "#2e3036",
   button: "#5561C0",
   clickable: {
     bg: "linear-gradient(180deg, #26292e, #24272c)",

+ 18 - 16
internal/models/porter_app.go

@@ -22,26 +22,28 @@ type PorterApp struct {
 	RepoName  string
 	GitBranch string
 
-	BuildContext string
-	Builder      string
-	Buildpacks   string
-	Dockerfile   string
+	BuildContext   string
+	Builder        string
+	Buildpacks     string
+	Dockerfile     string
+	PullRequestURL 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,
+		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,
+		PullRequestURL: a.PullRequestURL,
 	}
 }

+ 13 - 7
internal/repository/gorm/porter_app.go

@@ -27,19 +27,17 @@ func (repo *PorterAppRepository) CreatePorterApp(a *models.PorterApp) (*models.P
 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
-		}
-	*/
+	if err := repo.db.Where("cluster_id = ?", clusterID).Find(&apps).Error; err != nil {
+		return nil, err
+	}
 
 	return apps, nil
 }
 
-func (repo *PorterAppRepository) ReadPorterApp(clusterID uint, name string) (*models.PorterApp, error) {
+func (repo *PorterAppRepository) ReadPorterAppByName(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 {
+	if err := repo.db.Where("cluster_id = ? AND name = ?", clusterID, name).Limit(1).Find(&app).Error; err != nil {
 		return nil, err
 	}
 
@@ -53,3 +51,11 @@ func (repo *PorterAppRepository) UpdatePorterApp(app *models.PorterApp) (*models
 
 	return app, nil
 }
+
+func (repo *PorterAppRepository) DeletePorterApp(app *models.PorterApp) (*models.PorterApp, error) {
+	if err := repo.db.Delete(&app).Error; err != nil {
+		return nil, err
+	}
+
+	return app, nil
+}

+ 3 - 2
internal/repository/porter_app.go

@@ -6,8 +6,9 @@ import (
 
 // PorterAppRepository represents the set of queries on the PorterApp model
 type PorterAppRepository interface {
+	ReadPorterAppByName(clusterID uint, name string) (*models.PorterApp, error)
 	CreatePorterApp(app *models.PorterApp) (*models.PorterApp, error)
-	// ListPorterAppByClusterID(clusterID uint) ([]*models.PorterApp, error)
-	ReadPorterApp(clusterID uint, name string) (*models.PorterApp, error)
+	ListPorterAppByClusterID(clusterID uint) ([]*models.PorterApp, error)
 	UpdatePorterApp(app *models.PorterApp) (*models.PorterApp, error)
+	DeletePorterApp(app *models.PorterApp) (*models.PorterApp, error)
 }

Някои файлове не бяха показани, защото твърде много файлове са промени