Преглед изворни кода

Merge branch 'master' of github.com:porter-dev/porter into nico/por-222-add-babel-plugin-for-improve-debugging

jnfrati пре 4 година
родитељ
комит
f67ebe7a08
100 измењених фајлова са 4751 додато и 1790 уклоњено
  1. 3 0
      .gitignore
  2. 4 4
      Makefile
  3. 2 2
      api/server/authn/handler.go
  4. 8 6
      api/server/authn/session_helpers.go
  5. 51 0
      api/server/handlers/cluster/detect_agent_installed.go
  6. 111 0
      api/server/handlers/cluster/install_agent.go
  7. 62 14
      api/server/handlers/gitinstallation/get_accounts.go
  8. 56 28
      api/server/handlers/gitinstallation/get_buildpack.go
  9. 5 1
      api/server/handlers/handler.go
  10. 9 1
      api/server/handlers/infra/get_current.go
  11. 9 1
      api/server/handlers/infra/get_desired.go
  12. 213 0
      api/server/handlers/kube_events/create.go
  13. 46 0
      api/server/handlers/kube_events/get.go
  14. 96 0
      api/server/handlers/kube_events/get_log_buckets.go
  15. 97 0
      api/server/handlers/kube_events/get_logs.go
  16. 60 0
      api/server/handlers/kube_events/list.go
  17. 73 0
      api/server/handlers/namespace/get_pod.go
  18. 8 4
      api/server/handlers/registry/get_token.go
  19. 40 0
      api/server/handlers/release/create.go
  20. 14 3
      api/server/handlers/release/get.go
  21. 1 1
      api/server/handlers/release/get_steps.go
  22. 6 5
      api/server/handlers/release/ugprade.go
  23. 73 0
      api/server/handlers/release/update_build_config.go
  24. 3 3
      api/server/handlers/release/update_steps.go
  25. 5 4
      api/server/handlers/release/upgrade_webhook.go
  26. 13 2
      api/server/handlers/user/create.go
  27. 11 11
      api/server/handlers/user/github_callback.go
  28. 11 11
      api/server/handlers/user/google_callback.go
  29. 8 1
      api/server/handlers/user/login.go
  30. 200 0
      api/server/router/cluster.go
  31. 33 0
      api/server/router/namespace.go
  32. 30 0
      api/server/router/release.go
  33. 7 0
      api/server/router/router.go
  34. 3 0
      api/server/shared/config/env/envconfs.go
  35. 20 0
      api/types/build_config.go
  36. 0 5
      api/types/git_installation.go
  37. 91 0
      api/types/kube_events.go
  38. 4 0
      api/types/release.go
  39. 5 2
      cli/cmd/deploy/build.go
  40. 2 2
      cli/cmd/deploy/create.go
  41. 7 4
      cli/cmd/deploy/deploy.go
  42. 1 0
      cli/cmd/docker/agent.go
  43. 6 1
      cli/cmd/docker/builder.go
  44. 75 0
      cli/cmd/pack/logger.go
  45. 16 2
      cli/cmd/pack/pack.go
  46. 1 1
      cli/cmd/run.go
  47. 122 874
      dashboard/package-lock.json
  48. 1 0
      dashboard/package.json
  49. 157 0
      dashboard/src/components/Dropdown.tsx
  50. 1 0
      dashboard/src/components/ResourceTab.tsx
  51. 1 1
      dashboard/src/components/SaveButton.tsx
  52. 53 5
      dashboard/src/components/Selector.tsx
  53. 1 1
      dashboard/src/components/UnexpectedErrorPage.tsx
  54. 202 0
      dashboard/src/components/events/EventCard.tsx
  55. 360 0
      dashboard/src/components/events/SubEventsList.tsx
  56. 166 0
      dashboard/src/components/events/sub-events/LogBucketCard.tsx
  57. 57 0
      dashboard/src/components/events/sub-events/SubEventCard.tsx
  58. 214 0
      dashboard/src/components/events/useEvents.ts
  59. 89 0
      dashboard/src/components/events/useLastSeenPodStatus.ts
  60. 4 3
      dashboard/src/components/image-selector/ImageList.tsx
  61. 2 2
      dashboard/src/components/image-selector/TagList.tsx
  62. 9 2
      dashboard/src/components/porter-form/field-components/ResourceList.tsx
  63. 8 8
      dashboard/src/components/porter-form/types.ts
  64. 2 0
      dashboard/src/components/repo-selector/ActionConfEditor.tsx
  65. 512 135
      dashboard/src/components/repo-selector/ActionDetails.tsx
  66. 6 0
      dashboard/src/components/repo-selector/ContentsList.tsx
  67. 40 3
      dashboard/src/index.html
  68. 2 1
      dashboard/src/main/home/Home.tsx
  69. 5 1
      dashboard/src/main/home/cluster-dashboard/dashboard/Dashboard.tsx
  70. 215 0
      dashboard/src/main/home/cluster-dashboard/dashboard/events/EventsTab.tsx
  71. 7 5
      dashboard/src/main/home/cluster-dashboard/env-groups/EnvGroupDashboard.tsx
  72. 1 1
      dashboard/src/main/home/cluster-dashboard/env-groups/EnvGroupList.tsx
  73. 114 111
      dashboard/src/main/home/cluster-dashboard/expanded-chart/ExpandedChart.tsx
  74. 0 120
      dashboard/src/main/home/cluster-dashboard/expanded-chart/events/EventCard.tsx
  75. 0 94
      dashboard/src/main/home/cluster-dashboard/expanded-chart/events/EventDetail.tsx
  76. 189 159
      dashboard/src/main/home/cluster-dashboard/expanded-chart/events/EventsTab.tsx
  77. 25 28
      dashboard/src/main/home/cluster-dashboard/expanded-chart/status/StatusSection.tsx
  78. 3 0
      dashboard/src/main/home/launch/launch-flow/LaunchFlow.tsx
  79. 3 0
      dashboard/src/main/home/launch/launch-flow/SourcePage.tsx
  80. 4 5
      dashboard/src/main/home/modals/DeleteNamespaceModal.tsx
  81. 8 1
      dashboard/src/main/home/modals/EditInviteOrCollaboratorModal.tsx
  82. 7 5
      dashboard/src/main/home/modals/SkipProvisioningModal.tsx
  83. 60 48
      dashboard/src/main/home/navbar/Help.tsx
  84. 11 11
      dashboard/src/main/home/navbar/Navbar.tsx
  85. 1 1
      dashboard/src/main/home/onboarding/Onboarding.tsx
  86. 19 7
      dashboard/src/main/home/onboarding/components/ProviderSelector.tsx
  87. 15 0
      dashboard/src/main/home/onboarding/state/StateHandler.ts
  88. 11 3
      dashboard/src/main/home/onboarding/state/StepHandler.ts
  89. 106 4
      dashboard/src/main/home/onboarding/steps/ConnectRegistry/ConnectRegistry.tsx
  90. 193 0
      dashboard/src/main/home/onboarding/steps/ConnectRegistry/components/Registry.tsx
  91. 2 1
      dashboard/src/main/home/sidebar/Sidebar.tsx
  92. 79 0
      dashboard/src/shared/api.tsx
  93. 16 0
      dashboard/src/shared/types.tsx
  94. 7 7
      docker-compose.dev.yaml
  95. 4 2
      docker/Dockerfile
  96. 11 0
      docs/developing/setup-gh-app-locally.md
  97. 5 1
      docs/guides/https-and-custom-domains.md
  98. 4 2
      ee/docker/ee.Dockerfile
  99. 6 0
      ee/integrations/httpbackend/backend.go
  100. 12 19
      go.mod

+ 3 - 0
.gitignore

@@ -15,6 +15,9 @@ staging.sh
 *.key
 *.key
 bin
 bin
 
 
+# Local docs directories
+/docs/.obsidian
+
 # Local .terraform directories
 # Local .terraform directories
 **/.terraform/*
 **/.terraform/*
 
 

+ 4 - 4
Makefile

@@ -7,14 +7,14 @@ start-dev: install setup-env-files
 run-migrate-dev: install setup-env-files
 run-migrate-dev: install setup-env-files
 	bash ./scripts/dev-environment/RunMigrateDev.sh
 	bash ./scripts/dev-environment/RunMigrateDev.sh
 
 
-install: 
+install:
 	bash ./scripts/dev-environment/SetupEnvironment.sh
 	bash ./scripts/dev-environment/SetupEnvironment.sh
 
 
-setup-env-files: 
+setup-env-files:
 	bash ./scripts/dev-environment/CreateDefaultEnvFiles.sh
 	bash ./scripts/dev-environment/CreateDefaultEnvFiles.sh
 
 
-build-cli: 
+build-cli:
 	go build -ldflags="-w -s -X 'github.com/porter-dev/porter/cli/cmd.Version=${VERSION}'" -a -tags cli -o $(BINDIR)/porter ./cli
 	go build -ldflags="-w -s -X 'github.com/porter-dev/porter/cli/cmd.Version=${VERSION}'" -a -tags cli -o $(BINDIR)/porter ./cli
 
 
 build-cli-dev:
 build-cli-dev:
-	go build -tags cli -o $(BINDIR)/porter ./cli
+	go build -tags cli -o $(BINDIR)/porter ./cli

+ 2 - 2
api/server/authn/handler.go

@@ -105,9 +105,9 @@ func (authn *AuthN) handleForbiddenForSession(
 	if authn.redirect {
 	if authn.redirect {
 		// need state parameter to validate when redirected
 		// need state parameter to validate when redirected
 		if r.URL.RawQuery == "" {
 		if r.URL.RawQuery == "" {
-			session.Values["redirect"] = r.URL.Path
+			session.Values["redirect_uri"] = r.URL.Path
 		} else {
 		} else {
-			session.Values["redirect"] = r.URL.Path + "?" + r.URL.RawQuery
+			session.Values["redirect_uri"] = r.URL.Path + "?" + r.URL.RawQuery
 		}
 		}
 
 
 		session.Save(r, w)
 		session.Save(r, w)

+ 8 - 6
api/server/authn/session_helpers.go

@@ -12,25 +12,27 @@ func SaveUserAuthenticated(
 	r *http.Request,
 	r *http.Request,
 	config *config.Config,
 	config *config.Config,
 	user *models.User,
 	user *models.User,
-) error {
+) (string, error) {
 	session, err := config.Store.Get(r, config.ServerConf.CookieName)
 	session, err := config.Store.Get(r, config.ServerConf.CookieName)
 
 
 	if err != nil {
 	if err != nil {
-		return err
+		return "", err
 	}
 	}
 
 
 	var redirect string
 	var redirect string
 
 
-	if valR := session.Values["redirect"]; valR != nil {
-		redirect = session.Values["redirect"].(string)
+	if valR := session.Values["redirect_uri"]; valR != nil {
+		redirect = session.Values["redirect_uri"].(string)
 	}
 	}
 
 
 	session.Values["authenticated"] = true
 	session.Values["authenticated"] = true
 	session.Values["user_id"] = user.ID
 	session.Values["user_id"] = user.ID
 	session.Values["email"] = user.Email
 	session.Values["email"] = user.Email
-	session.Values["redirect"] = redirect
 
 
-	return session.Save(r, w)
+	// we unset the redirect uri after login
+	session.Values["redirect_uri"] = ""
+
+	return redirect, session.Save(r, w)
 }
 }
 
 
 func SaveUserUnauthenticated(
 func SaveUserUnauthenticated(

+ 51 - 0
api/server/handlers/cluster/detect_agent_installed.go

@@ -0,0 +1,51 @@
+package cluster
+
+import (
+	"errors"
+	"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/apierrors"
+	"github.com/porter-dev/porter/api/server/shared/config"
+	"github.com/porter-dev/porter/api/types"
+	"github.com/porter-dev/porter/internal/kubernetes"
+	"github.com/porter-dev/porter/internal/models"
+)
+
+type DetectAgentInstalledHandler struct {
+	handlers.PorterHandler
+	authz.KubernetesAgentGetter
+}
+
+func NewDetectAgentInstalledHandler(
+	config *config.Config,
+) *DetectAgentInstalledHandler {
+	return &DetectAgentInstalledHandler{
+		PorterHandler:         handlers.NewDefaultPorterHandler(config, nil, nil),
+		KubernetesAgentGetter: authz.NewOutOfClusterAgentGetter(config),
+	}
+}
+
+func (c *DetectAgentInstalledHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	cluster, _ := r.Context().Value(types.ClusterScope).(*models.Cluster)
+
+	agent, err := c.GetAgent(r, cluster, "")
+
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	_, err = agent.GetPorterAgent()
+
+	if targetErr := kubernetes.IsNotFoundError; err != nil && errors.Is(err, targetErr) {
+		http.NotFound(w, r)
+		return
+	} else if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	w.WriteHeader(http.StatusOK)
+}

+ 111 - 0
api/server/handlers/cluster/install_agent.go

@@ -0,0 +1,111 @@
+package cluster
+
+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/auth/token"
+	"github.com/porter-dev/porter/internal/helm"
+	"github.com/porter-dev/porter/internal/helm/loader"
+	"github.com/porter-dev/porter/internal/models"
+)
+
+type InstallAgentHandler struct {
+	handlers.PorterHandlerReadWriter
+	authz.KubernetesAgentGetter
+}
+
+func NewInstallAgentHandler(
+	config *config.Config,
+	decoderValidator shared.RequestDecoderValidator,
+	writer shared.ResultWriter,
+) *InstallAgentHandler {
+	return &InstallAgentHandler{
+		PorterHandlerReadWriter: handlers.NewDefaultPorterHandler(config, decoderValidator, writer),
+		KubernetesAgentGetter:   authz.NewOutOfClusterAgentGetter(config),
+	}
+}
+
+func (c *InstallAgentHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	proj, _ := r.Context().Value(types.ProjectScope).(*models.Project)
+	user, _ := r.Context().Value(types.UserScope).(*models.User)
+	cluster, _ := r.Context().Value(types.ClusterScope).(*models.Cluster)
+	helmAgent, err := c.GetHelmAgent(r, cluster, "porter-agent-system")
+
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	chart, err := loader.LoadChartPublic(c.Config().ServerConf.DefaultAddonHelmRepoURL, "porter-agent", "")
+
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	// create namespace if not exists
+	_, err = helmAgent.K8sAgent.CreateNamespace("porter-agent-system")
+
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	// add api token to values
+	jwt, err := token.GetTokenForAPI(user.ID, proj.ID)
+
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	encoded, err := jwt.EncodeToken(c.Config().TokenConf)
+
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	porterAgentValues := map[string]interface{}{
+		"agent": map[string]interface{}{
+			"image":       "public.ecr.aws/o1j4x7p4/porter-agent:latest",
+			"porterHost":  c.Config().ServerConf.ServerURL,
+			"porterPort":  "443",
+			"porterToken": encoded,
+			"privateRegistry": map[string]interface{}{
+				"enabled": false,
+			},
+			"clusterID": fmt.Sprintf("%d", cluster.ID),
+			"projectID": fmt.Sprintf("%d", proj.ID),
+		},
+	}
+
+	conf := &helm.InstallChartConfig{
+		Chart:     chart,
+		Name:      "porter-agent",
+		Namespace: "porter-agent-system",
+		Cluster:   cluster,
+		Repo:      c.Repo(),
+		Values:    porterAgentValues,
+	}
+
+	_, err = helmAgent.InstallChart(conf, c.Config().DOConf)
+
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(
+			fmt.Errorf("error installing a new chart: %s", err.Error()),
+			http.StatusBadRequest,
+		))
+
+		return
+	}
+
+	w.WriteHeader(http.StatusOK)
+}

+ 62 - 14
api/server/handlers/gitinstallation/get_accounts.go

@@ -4,6 +4,7 @@ import (
 	"context"
 	"context"
 	"net/http"
 	"net/http"
 	"sort"
 	"sort"
+	"time"
 
 
 	"github.com/google/go-github/github"
 	"github.com/google/go-github/github"
 	"github.com/porter-dev/porter/api/server/authz"
 	"github.com/porter-dev/porter/api/server/authz"
@@ -31,6 +32,43 @@ func NewGetGithubAppAccountsHandler(
 	}
 	}
 }
 }
 
 
+func (c *GetGithubAppAccountsHandler) getOrgList(ctx context.Context,
+	client *github.Client,
+	orgsChan chan<- *github.Organization,
+	errChan chan<- error) {
+	defer close(orgsChan)
+	defer close(errChan)
+
+	page := 1
+
+	for {
+		select {
+		case <-ctx.Done():
+			return
+		default:
+			orgs, pages, err := client.Organizations.List(context.Background(), "", &github.ListOptions{
+				PerPage: 100,
+				Page:    page,
+			})
+
+			if err != nil {
+				errChan <- err
+				return
+			}
+
+			for _, org := range orgs {
+				orgsChan <- org
+			}
+
+			if pages.NextPage == 0 {
+				return
+			} else {
+				page = pages.NextPage
+			}
+		}
+	}
+}
+
 func (c *GetGithubAppAccountsHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
 func (c *GetGithubAppAccountsHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
 	tok, err := GetGithubAppOauthTokenFromRequest(c.Config(), r)
 	tok, err := GetGithubAppOauthTokenFromRequest(c.Config(), r)
 
 
@@ -42,26 +80,36 @@ func (c *GetGithubAppAccountsHandler) ServeHTTP(w http.ResponseWriter, r *http.R
 	client := github.NewClient(c.Config().GithubAppConf.Client(oauth2.NoContext, tok))
 	client := github.NewClient(c.Config().GithubAppConf.Client(oauth2.NoContext, tok))
 	res := &types.GetGithubAppAccountsResponse{}
 	res := &types.GetGithubAppAccountsResponse{}
 
 
-	for {
-		orgs, pages, err := client.Organizations.List(context.Background(), "", &github.ListOptions{
-			PerPage: 100,
-			Page:    1,
-		})
+	resultChannel := make(chan *github.Organization, 10)
+	errChan := make(chan error)
 
 
-		if err != nil {
-			continue
-		}
+	ctx, cancel := context.WithTimeout(r.Context(), 3*time.Second)
+	defer cancel()
 
 
-		for _, org := range orgs {
-			res.Accounts = append(res.Accounts, *org.Login)
-		}
+	go c.getOrgList(ctx, client, resultChannel, errChan)
 
 
-		if pages.NextPage == 0 {
-			break
+resultOrErrorReader:
+	for {
+		select {
+		case result, ok := <-resultChannel:
+			if ok {
+				res.Accounts = append(res.Accounts, *result.Login)
+			} else {
+				// channel has been closed now
+				break resultOrErrorReader
+			}
+		case err, ok := <-errChan:
+			if ok {
+				c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+				return
+			} else {
+				// nothing in error, must be a close event
+				break resultOrErrorReader
+			}
 		}
 		}
 	}
 	}
 
 
-	authUser, _, err := client.Users.Get(context.Background(), "")
+	authUser, _, err := client.Users.Get(r.Context(), "")
 
 
 	if err != nil {
 	if err != nil {
 		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
 		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))

+ 56 - 28
api/server/handlers/gitinstallation/get_buildpack.go

@@ -2,7 +2,9 @@ package gitinstallation
 
 
 import (
 import (
 	"context"
 	"context"
+	"fmt"
 	"net/http"
 	"net/http"
+	"sync"
 
 
 	"github.com/google/go-github/github"
 	"github.com/google/go-github/github"
 	"github.com/porter-dev/porter/api/server/authz"
 	"github.com/porter-dev/porter/api/server/authz"
@@ -11,8 +13,27 @@ import (
 	"github.com/porter-dev/porter/api/server/shared/apierrors"
 	"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/config"
 	"github.com/porter-dev/porter/api/types"
 	"github.com/porter-dev/porter/api/types"
+	"github.com/porter-dev/porter/internal/integrations/buildpacks"
 )
 )
 
 
+func initBuilderInfo() map[string]*buildpacks.BuilderInfo {
+	builders := make(map[string]*buildpacks.BuilderInfo)
+	builders[buildpacks.PaketoBuilder] = &buildpacks.BuilderInfo{
+		Name: "Paketo",
+		Builders: []string{
+			"paketobuildpacks/builder:full",
+		},
+	}
+	builders[buildpacks.HerokuBuilder] = &buildpacks.BuilderInfo{
+		Name: "Heroku",
+		Builders: []string{
+			"heroku/buildpacks:20",
+			"heroku/buildpacks:18",
+		},
+	}
+	return builders
+}
+
 type GithubGetBuildpackHandler struct {
 type GithubGetBuildpackHandler struct {
 	handlers.PorterHandlerReadWriter
 	handlers.PorterHandlerReadWriter
 	authz.KubernetesAgentGetter
 	authz.KubernetesAgentGetter
@@ -71,34 +92,41 @@ func (c *GithubGetBuildpackHandler) ServeHTTP(w http.ResponseWriter, r *http.Req
 		return
 		return
 	}
 	}
 
 
-	var BREQS = map[string]string{
-		"requirements.txt": "Python",
-		"Gemfile":          "Ruby",
-		"package.json":     "Node.js",
-		"pom.xml":          "Java",
-		"composer.json":    "PHP",
-	}
-
-	res := &types.GetBuildpackResponse{
-		Valid: true,
+	builderInfoMap := initBuilderInfo()
+	var wg sync.WaitGroup
+	wg.Add(len(buildpacks.Runtimes))
+	for i := range buildpacks.Runtimes {
+		go func(idx int) {
+			defer func() {
+				if rec := recover(); rec != nil {
+					c.HandleAPIError(w, r, apierrors.NewErrInternal(fmt.Errorf("panic detected in runtime detection")))
+					return
+				}
+			}()
+			buildpacks.Runtimes[idx].Detect(
+				client, directoryContents, owner, name, request.Dir, repoContentOptions,
+				builderInfoMap[buildpacks.PaketoBuilder], builderInfoMap[buildpacks.HerokuBuilder],
+			)
+			wg.Done()
+		}(i)
 	}
 	}
-
-	matches := 0
-
-	for i := range directoryContents {
-		name := *directoryContents[i].Name
-
-		bname, ok := BREQS[name]
-		if ok {
-			matches++
-			res.Name = bname
-		}
+	wg.Wait()
+
+	// FIXME: add Java buildpacks
+	builderInfoMap[buildpacks.PaketoBuilder].Others = append(builderInfoMap[buildpacks.PaketoBuilder].Others,
+		buildpacks.BuildpackInfo{
+			Name:      "Java",
+			Buildpack: "gcr.io/paketo-buildpacks/java",
+		})
+	builderInfoMap[buildpacks.HerokuBuilder].Others = append(builderInfoMap[buildpacks.HerokuBuilder].Others,
+		buildpacks.BuildpackInfo{
+			Name:      "Java",
+			Buildpack: "heroku/java",
+		})
+
+	var builders []*buildpacks.BuilderInfo
+	for _, v := range builderInfoMap {
+		builders = append(builders, v)
 	}
 	}
-
-	if matches != 1 {
-		res.Valid = false
-		res.Name = ""
-	}
-
-	c.WriteResult(w, r, res)
+	c.WriteResult(w, r, builders)
 }
 }

+ 5 - 1
api/server/handlers/handler.go

@@ -90,7 +90,11 @@ func (d *DefaultPorterHandler) PopulateOAuthSession(w http.ResponseWriter, r *ht
 
 
 	// need state parameter to validate when redirected
 	// need state parameter to validate when redirected
 	session.Values["state"] = state
 	session.Values["state"] = state
-	session.Values["redirect_uri"] = r.URL.Query().Get("redirect_uri")
+
+	// check if redirect uri is populated, then overwrite
+	if redirect := r.URL.Query().Get("redirect_uri"); redirect != "" {
+		session.Values["redirect_uri"] = redirect
+	}
 
 
 	if isProject {
 	if isProject {
 		project, _ := r.Context().Value(types.ProjectScope).(*models.Project)
 		project, _ := r.Context().Value(types.ProjectScope).(*models.Project)

+ 9 - 1
api/server/handlers/infra/get_current.go

@@ -1,6 +1,7 @@
 package infra
 package infra
 
 
 import (
 import (
+	"errors"
 	"net/http"
 	"net/http"
 
 
 	"github.com/porter-dev/porter/api/server/handlers"
 	"github.com/porter-dev/porter/api/server/handlers"
@@ -34,7 +35,14 @@ func (c *InfraGetCurrentHandler) ServeHTTP(w http.ResponseWriter, r *http.Reques
 	// get the unique infra name and query from the TF HTTP backend
 	// get the unique infra name and query from the TF HTTP backend
 	current, err := client.GetCurrentState(infra.GetUniqueName())
 	current, err := client.GetCurrentState(infra.GetUniqueName())
 
 
-	if err != nil {
+	if err != nil && errors.Is(err, httpbackend.ErrNotFound) {
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(
+			err,
+			http.StatusNotFound,
+		))
+
+		return
+	} else if err != nil {
 		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
 		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
 		return
 		return
 	}
 	}

+ 9 - 1
api/server/handlers/infra/get_desired.go

@@ -1,6 +1,7 @@
 package infra
 package infra
 
 
 import (
 import (
+	"errors"
 	"net/http"
 	"net/http"
 
 
 	"github.com/porter-dev/porter/api/server/handlers"
 	"github.com/porter-dev/porter/api/server/handlers"
@@ -34,7 +35,14 @@ func (c *InfraGetDesiredHandler) ServeHTTP(w http.ResponseWriter, r *http.Reques
 	// get the unique infra name and query from the TF HTTP backend
 	// get the unique infra name and query from the TF HTTP backend
 	desired, err := client.GetDesiredState(infra.GetUniqueName())
 	desired, err := client.GetDesiredState(infra.GetUniqueName())
 
 
-	if err != nil {
+	if err != nil && errors.Is(err, httpbackend.ErrNotFound) {
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(
+			err,
+			http.StatusNotFound,
+		))
+
+		return
+	} else if err != nil {
 		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
 		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
 		return
 		return
 	}
 	}

+ 213 - 0
api/server/handlers/kube_events/create.go

@@ -0,0 +1,213 @@
+package kube_events
+
+import (
+	"errors"
+	"fmt"
+	"net/http"
+	"net/url"
+	"strings"
+	"time"
+
+	"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/integrations/slack"
+	"github.com/porter-dev/porter/internal/models"
+	"gorm.io/gorm"
+)
+
+type CreateKubeEventHandler struct {
+	handlers.PorterHandlerReadWriter
+	authz.KubernetesAgentGetter
+}
+
+func NewCreateKubeEventHandler(
+	config *config.Config,
+	decoderValidator shared.RequestDecoderValidator,
+	writer shared.ResultWriter,
+) *CreateKubeEventHandler {
+	return &CreateKubeEventHandler{
+		PorterHandlerReadWriter: handlers.NewDefaultPorterHandler(config, decoderValidator, writer),
+		KubernetesAgentGetter:   authz.NewOutOfClusterAgentGetter(config),
+	}
+}
+
+func (c *CreateKubeEventHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	proj, _ := r.Context().Value(types.ProjectScope).(*models.Project)
+	cluster, _ := r.Context().Value(types.ClusterScope).(*models.Cluster)
+
+	request := &types.CreateKubeEventRequest{}
+
+	if ok := c.DecodeAndValidate(w, r, request); !ok {
+		return
+	}
+
+	// Look for an event matching by the name, namespace, and was last updated within the
+	// grouping threshold time. If so, we append a subevent to the existing event.
+	kubeEvent, err := c.Repo().KubeEvent().ReadEventByGroup(proj.ID, cluster.ID, &types.GroupOptions{
+		Name:          request.Name,
+		Namespace:     request.Namespace,
+		ResourceType:  request.ResourceType,
+		ThresholdTime: time.Now().Add(-15 * time.Minute),
+	})
+
+	foundMatchedEvent := kubeEvent != nil
+
+	if !foundMatchedEvent {
+		kubeEvent, err = c.Repo().KubeEvent().CreateEvent(&models.KubeEvent{
+			ProjectID:    proj.ID,
+			ClusterID:    cluster.ID,
+			ResourceType: request.ResourceType,
+			Name:         request.Name,
+			OwnerType:    request.OwnerType,
+			OwnerName:    request.OwnerName,
+			Namespace:    request.Namespace,
+		})
+
+		if err != nil {
+			c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+			return
+		}
+	}
+
+	// append the subevent to the event
+	err = c.Repo().KubeEvent().AppendSubEvent(kubeEvent, &models.KubeSubEvent{
+		EventType: request.EventType,
+		Message:   request.Message,
+		Reason:    request.Reason,
+		Timestamp: request.Timestamp,
+	})
+
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	w.WriteHeader(http.StatusCreated)
+
+	if strings.ToLower(string(request.EventType)) == "critical" && strings.ToLower(request.ResourceType) == "pod" {
+		err := notifyPodCrashing(c.Config(), proj, cluster, request)
+
+		if err != nil {
+			c.HandleAPIErrorNoWrite(w, r, apierrors.NewErrInternal(err))
+		}
+	}
+}
+
+func notifyPodCrashing(
+	config *config.Config,
+	project *models.Project,
+	cluster *models.Cluster,
+	event *types.CreateKubeEventRequest,
+) error {
+	// attempt to get a matching Porter release to get the notification configuration
+	var conf *models.NotificationConfig
+	var notifConfig *types.NotificationConfig
+	var err error
+	matchedRel := getMatchedPorterRelease(config, cluster.ID, event.OwnerName, event.Namespace)
+
+	// for now, we only notify for Porter releases that have been deployed through Porter
+	if matchedRel == nil {
+		return nil
+	}
+
+	conf, err = config.Repo.NotificationConfig().ReadNotificationConfig(matchedRel.NotificationConfig)
+
+	if err != nil && errors.Is(err, gorm.ErrRecordNotFound) {
+		conf = &models.NotificationConfig{
+			Enabled: true,
+			Success: true,
+			Failure: true,
+		}
+
+		conf, err = config.Repo.NotificationConfig().CreateNotificationConfig(conf)
+
+		if err != nil {
+			return err
+		}
+
+		if err != nil {
+			return err
+		}
+
+		matchedRel.NotificationConfig = conf.ID
+		matchedRel, err = config.Repo.Release().UpdateRelease(matchedRel)
+
+		if err != nil {
+			return err
+		}
+
+		notifConfig = conf.ToNotificationConfigType()
+	} else if err != nil {
+		return err
+	} else if err == nil && conf != nil {
+		if !conf.ShouldNotify() {
+			return nil
+		}
+
+		notifConfig = conf.ToNotificationConfigType()
+	}
+
+	slackInts, _ := config.Repo.SlackIntegration().ListSlackIntegrationsByProjectID(project.ID)
+
+	notifier := slack.NewSlackNotifier(notifConfig, slackInts...)
+
+	notifyOpts := &slack.NotifyOpts{
+		ProjectID:   cluster.ProjectID,
+		ClusterID:   cluster.ID,
+		ClusterName: cluster.Name,
+		Name:        event.OwnerName,
+		Namespace:   event.Namespace,
+		Info:        fmt.Sprintf("%s:%s", event.Reason, event.Message),
+		URL: fmt.Sprintf(
+			"%s/applications/%s/%s/%s?project_id=%d",
+			config.ServerConf.ServerURL,
+			url.PathEscape(cluster.Name),
+			matchedRel.Namespace,
+			matchedRel.Name,
+			cluster.ProjectID,
+		),
+	}
+
+	notifyOpts.Status = slack.StatusPodCrashed
+
+	err = notifier.Notify(notifyOpts)
+
+	if err != nil {
+		return err
+	}
+
+	// update the last updated time
+	if matchedRel != nil && conf != nil {
+		conf.LastNotifiedTime = time.Now()
+		conf, err = config.Repo.NotificationConfig().UpdateNotificationConfig(conf)
+	}
+
+	return err
+}
+
+// getMatchedPorterRelease attempts to find a matching Porter release from the name of a controller.
+// For example, if the controller has a suffix "-web", it is likely a Porter web application, and
+// so we query for a Porter release with a matching name. Returns nil if no match is found
+func getMatchedPorterRelease(config *config.Config, clusterID uint, ownerName, namespace string) *models.Release {
+	matchingName := ""
+
+	if strings.Contains(ownerName, "-web") {
+		matchingName = strings.Split(ownerName, "-web")[0]
+	} else if strings.Contains(ownerName, "-worker") {
+		matchingName = strings.Split(ownerName, "-worker")[0]
+	} else if strings.Contains(ownerName, "-job") {
+		matchingName = strings.Split(ownerName, "-job")[0]
+	}
+
+	rel, err := config.Repo.Release().ReadRelease(clusterID, matchingName, namespace)
+
+	if err != nil {
+		return nil
+	}
+
+	return rel
+}

+ 46 - 0
api/server/handlers/kube_events/get.go

@@ -0,0 +1,46 @@
+package kube_events
+
+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 GetKubeEventHandler struct {
+	handlers.PorterHandlerReadWriter
+	authz.KubernetesAgentGetter
+}
+
+func NewGetKubeEventHandler(
+	config *config.Config,
+	decoderValidator shared.RequestDecoderValidator,
+	writer shared.ResultWriter,
+) *GetKubeEventHandler {
+	return &GetKubeEventHandler{
+		PorterHandlerReadWriter: handlers.NewDefaultPorterHandler(config, decoderValidator, writer),
+		KubernetesAgentGetter:   authz.NewOutOfClusterAgentGetter(config),
+	}
+}
+
+func (c *GetKubeEventHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	proj, _ := r.Context().Value(types.ProjectScope).(*models.Project)
+	cluster, _ := r.Context().Value(types.ClusterScope).(*models.Cluster)
+	kubeEventID, _ := requestutils.GetURLParamUint(r, types.URLParamKubeEventID)
+
+	// handle write to the database
+	kubeEvent, err := c.Repo().KubeEvent().ReadEvent(kubeEventID, proj.ID, cluster.ID)
+
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	c.WriteResult(w, r, kubeEvent.ToKubeEventType())
+}

+ 96 - 0
api/server/handlers/kube_events/get_log_buckets.go

@@ -0,0 +1,96 @@
+package kube_events
+
+import (
+	"fmt"
+	"net/http"
+	"strings"
+
+	"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/kubernetes/porter_agent"
+	"github.com/porter-dev/porter/internal/models"
+)
+
+type GetKubeEventLogBucketsHandler struct {
+	handlers.PorterHandlerReadWriter
+	authz.KubernetesAgentGetter
+}
+
+func NewGetKubeEventLogBucketsHandler(
+	config *config.Config,
+	decoderValidator shared.RequestDecoderValidator,
+	writer shared.ResultWriter,
+) *GetKubeEventLogBucketsHandler {
+	return &GetKubeEventLogBucketsHandler{
+		PorterHandlerReadWriter: handlers.NewDefaultPorterHandler(config, decoderValidator, writer),
+		KubernetesAgentGetter:   authz.NewOutOfClusterAgentGetter(config),
+	}
+}
+
+func (c *GetKubeEventLogBucketsHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	proj, _ := r.Context().Value(types.ProjectScope).(*models.Project)
+	cluster, _ := r.Context().Value(types.ClusterScope).(*models.Cluster)
+	kubeEventID, _ := requestutils.GetURLParamUint(r, types.URLParamKubeEventID)
+
+	kubeEvent, err := c.Repo().KubeEvent().ReadEvent(kubeEventID, proj.ID, cluster.ID)
+
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	// if the kube event is not a pod type, throw a bad request error to the user
+	if strings.ToLower(kubeEvent.ResourceType) != "pod" {
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(
+			fmt.Errorf("event resource type must be pod to get logs"),
+			http.StatusBadRequest,
+		))
+
+		return
+	}
+
+	req := &types.GetKubeEventLogsRequest{}
+
+	if ok := c.DecodeAndValidate(w, r, req); !ok {
+		return
+	}
+
+	agent, err := c.GetAgent(r, cluster, "")
+
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	// get agent service
+	agentSvc, err := porter_agent.GetAgentService(agent.Clientset)
+
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	resp, err := porter_agent.GetLogBucketsFromPorterAgent(agent.Clientset, agentSvc, &porter_agent.LogBucketPathOpts{
+		Pod:       kubeEvent.Name,
+		Namespace: kubeEvent.Namespace,
+	})
+
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	if resp.Error != "" {
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(fmt.Errorf(resp.Error), http.StatusBadRequest))
+		return
+	}
+
+	c.WriteResult(w, r, &types.GetKubeEventLogBucketsResponse{
+		LogBuckets: resp.AvailableBuckets,
+	})
+}

+ 97 - 0
api/server/handlers/kube_events/get_logs.go

@@ -0,0 +1,97 @@
+package kube_events
+
+import (
+	"fmt"
+	"net/http"
+	"strings"
+
+	"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/kubernetes/porter_agent"
+	"github.com/porter-dev/porter/internal/models"
+)
+
+type GetKubeEventLogsHandler struct {
+	handlers.PorterHandlerReadWriter
+	authz.KubernetesAgentGetter
+}
+
+func NewGetKubeEventLogsHandler(
+	config *config.Config,
+	decoderValidator shared.RequestDecoderValidator,
+	writer shared.ResultWriter,
+) *GetKubeEventLogsHandler {
+	return &GetKubeEventLogsHandler{
+		PorterHandlerReadWriter: handlers.NewDefaultPorterHandler(config, decoderValidator, writer),
+		KubernetesAgentGetter:   authz.NewOutOfClusterAgentGetter(config),
+	}
+}
+
+func (c *GetKubeEventLogsHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	proj, _ := r.Context().Value(types.ProjectScope).(*models.Project)
+	cluster, _ := r.Context().Value(types.ClusterScope).(*models.Cluster)
+	kubeEventID, _ := requestutils.GetURLParamUint(r, types.URLParamKubeEventID)
+
+	kubeEvent, err := c.Repo().KubeEvent().ReadEvent(kubeEventID, proj.ID, cluster.ID)
+
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	// if the kube event is not a pod type, throw a bad request error to the user
+	if strings.ToLower(kubeEvent.ResourceType) != "pod" {
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(
+			fmt.Errorf("event resource type must be pod to get logs"),
+			http.StatusBadRequest,
+		))
+
+		return
+	}
+
+	req := &types.GetKubeEventLogsRequest{}
+
+	if ok := c.DecodeAndValidate(w, r, req); !ok {
+		return
+	}
+
+	agent, err := c.GetAgent(r, cluster, "")
+
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	// get agent service
+	agentSvc, err := porter_agent.GetAgentService(agent.Clientset)
+
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	resp, err := porter_agent.GetLogsFromPorterAgent(agent.Clientset, agentSvc, &porter_agent.LogPathOpts{
+		Timestamp: req.Timestamp,
+		Pod:       kubeEvent.Name,
+		Namespace: kubeEvent.Namespace,
+	})
+
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	if resp.Error != "" {
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(fmt.Errorf(resp.Error), http.StatusBadRequest))
+		return
+	}
+
+	c.WriteResult(w, r, &types.GetKubeEventLogsResponse{
+		Logs: resp.Logs,
+	})
+}

+ 60 - 0
api/server/handlers/kube_events/list.go

@@ -0,0 +1,60 @@
+package kube_events
+
+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/types"
+	"github.com/porter-dev/porter/internal/models"
+)
+
+type ListKubeEventsHandler struct {
+	handlers.PorterHandlerReadWriter
+	authz.KubernetesAgentGetter
+}
+
+func NewListKubeEventsHandler(
+	config *config.Config,
+	decoderValidator shared.RequestDecoderValidator,
+	writer shared.ResultWriter,
+) *ListKubeEventsHandler {
+	return &ListKubeEventsHandler{
+		PorterHandlerReadWriter: handlers.NewDefaultPorterHandler(config, decoderValidator, writer),
+		KubernetesAgentGetter:   authz.NewOutOfClusterAgentGetter(config),
+	}
+}
+
+func (c *ListKubeEventsHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	proj, _ := r.Context().Value(types.ProjectScope).(*models.Project)
+	cluster, _ := r.Context().Value(types.ClusterScope).(*models.Cluster)
+
+	request := &types.ListKubeEventRequest{}
+
+	if ok := c.DecodeAndValidate(w, r, request); !ok {
+		return
+	}
+
+	kubeEvents, count, err := c.Repo().KubeEvent().ListEventsByProjectID(proj.ID, cluster.ID, request)
+
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	resp := &types.ListKubeEventsResponse{
+		Count:      count,
+		Limit:      request.Limit,
+		Skip:       request.Skip,
+		KubeEvents: []*types.KubeEvent{},
+	}
+
+	for _, kubeEvent := range kubeEvents {
+		resp.KubeEvents = append(resp.KubeEvents, kubeEvent.ToKubeEventType())
+	}
+
+	c.WriteResult(w, r, resp)
+}

+ 73 - 0
api/server/handlers/namespace/get_pod.go

@@ -0,0 +1,73 @@
+package namespace
+
+import (
+	"errors"
+	"fmt"
+	"net/http"
+
+	"github.com/porter-dev/porter/api/server/authz"
+	"github.com/porter-dev/porter/api/server/handlers"
+	"github.com/porter-dev/porter/api/server/shared"
+	"github.com/porter-dev/porter/api/server/shared/apierrors"
+	"github.com/porter-dev/porter/api/server/shared/config"
+	"github.com/porter-dev/porter/api/server/shared/requestutils"
+	"github.com/porter-dev/porter/api/types"
+	"github.com/porter-dev/porter/internal/kubernetes"
+	"github.com/porter-dev/porter/internal/models"
+)
+
+type GetPodHandler struct {
+	handlers.PorterHandlerReadWriter
+	authz.KubernetesAgentGetter
+}
+
+func NewGetPodHandler(
+	config *config.Config,
+	writer shared.ResultWriter,
+) *GetPodHandler {
+	return &GetPodHandler{
+		PorterHandlerReadWriter: handlers.NewDefaultPorterHandler(config, nil, writer),
+		KubernetesAgentGetter:   authz.NewOutOfClusterAgentGetter(config),
+	}
+}
+
+func (c *GetPodHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	cluster, _ := r.Context().Value(types.ClusterScope).(*models.Cluster)
+
+	agent, err := c.GetAgent(r, cluster, "")
+
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	name, err := requestutils.GetURLParamString(r, types.URLParamPodName)
+
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	namespace, err := requestutils.GetURLParamString(r, types.URLParamNamespace)
+
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	pod, err := agent.GetPodByName(name, namespace)
+
+	if errors.Is(err, kubernetes.IsNotFoundError) {
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(
+			fmt.Errorf("pod %s/%s was not found", namespace, name),
+			http.StatusNotFound,
+		))
+
+		return
+	} else if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	c.WriteResult(w, r, pod)
+}

+ 8 - 4
api/server/handlers/registry/get_token.go

@@ -130,15 +130,19 @@ func (c *RegistryGetGCRTokenHandler) ServeHTTP(w http.ResponseWriter, r *http.Re
 		if reg.GCPIntegrationID != 0 && strings.Contains(reg.URL, request.ServerURL) {
 		if reg.GCPIntegrationID != 0 && strings.Contains(reg.URL, request.ServerURL) {
 			_reg := registry.Registry(*reg)
 			_reg := registry.Registry(*reg)
 
 
-			tokenCache, err := _reg.GetGCRToken(c.Repo())
+			oauthTok, err := _reg.GetGCRToken(c.Repo())
 
 
-			if err != nil {
+			// if the oauth token is not nil, but the error is not nil, we still return the token
+			// but log an error
+			if oauthTok != nil && err != nil {
+				c.HandleAPIErrorNoWrite(w, r, apierrors.NewErrInternal(err))
+			} else if err != nil {
 				c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
 				c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
 				return
 				return
 			}
 			}
 
 
-			token = string(tokenCache.Token)
-			expiresAt = &tokenCache.Expiry
+			token = oauthTok.AccessToken
+			expiresAt = &oauthTok.Expiry
 			break
 			break
 		}
 		}
 	}
 	}

+ 40 - 0
api/server/handlers/release/create.go

@@ -1,6 +1,7 @@
 package release
 package release
 
 
 import (
 import (
+	"encoding/json"
 	"fmt"
 	"fmt"
 	"net/http"
 	"net/http"
 	"strings"
 	"strings"
@@ -134,6 +135,15 @@ func (c *CreateReleaseHandler) ServeHTTP(w http.ResponseWriter, r *http.Request)
 		}
 		}
 	}
 	}
 
 
+	if request.BuildConfig != nil {
+		_, err = createBuildConfig(c.Config(), release, request.BuildConfig)
+	}
+
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
 	c.Config().AnalyticsClient.Track(analytics.ApplicationLaunchSuccessTrack(
 	c.Config().AnalyticsClient.Track(analytics.ApplicationLaunchSuccessTrack(
 		&analytics.ApplicationLaunchSuccessTrackOpts{
 		&analytics.ApplicationLaunchSuccessTrackOpts{
 			ApplicationScopedTrackOpts: analytics.GetApplicationScopedTrackOpts(
 			ApplicationScopedTrackOpts: analytics.GetApplicationScopedTrackOpts(
@@ -306,6 +316,36 @@ func createGitAction(
 	return ga.ToGitActionConfigType(), workflowYAML, nil
 	return ga.ToGitActionConfigType(), workflowYAML, nil
 }
 }
 
 
+func createBuildConfig(
+	config *config.Config,
+	release *models.Release,
+	bcRequest *types.CreateBuildConfigRequest,
+) (*types.BuildConfig, error) {
+	data, err := json.Marshal(bcRequest.Config)
+	if err != nil {
+		return nil, err
+	}
+
+	// handle write to the database
+	bc, err := config.Repo.BuildConfig().CreateBuildConfig(&models.BuildConfig{
+		Builder:    bcRequest.Builder,
+		Buildpacks: strings.Join(bcRequest.Buildpacks, ","),
+		Config:     data,
+	})
+	if err != nil {
+		return nil, err
+	}
+
+	release.BuildConfig = bc.ID
+
+	_, err = config.Repo.Release().UpdateRelease(release)
+	if err != nil {
+		return nil, err
+	}
+
+	return bc.ToBuildConfigType(), nil
+}
+
 type containerEnvConfig struct {
 type containerEnvConfig struct {
 	Container struct {
 	Container struct {
 		Env struct {
 		Env struct {

+ 14 - 3
api/server/handlers/release/get.go

@@ -52,6 +52,17 @@ func (c *ReleaseGetHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
 		if release.GitActionConfig != nil {
 		if release.GitActionConfig != nil {
 			res.GitActionConfig = release.GitActionConfig.ToGitActionConfigType()
 			res.GitActionConfig = release.GitActionConfig.ToGitActionConfigType()
 		}
 		}
+
+		if release.BuildConfig != 0 {
+			bc, err := c.Repo().BuildConfig().GetBuildConfig(release.BuildConfig)
+
+			if err != nil {
+				c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+				return
+			}
+
+			res.BuildConfig = bc.ToBuildConfigType()
+		}
 	} else if err != gorm.ErrRecordNotFound {
 	} else if err != gorm.ErrRecordNotFound {
 		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
 		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
 		return
 		return
@@ -136,7 +147,7 @@ tabs:
   label: Certificates
   label: Certificates
   sections:
   sections:
   - name: section_one
   - name: section_one
-    contents: 
+    contents:
     - type: heading
     - type: heading
       label: Certificates
       label: Certificates
     - type: resource-list
     - type: resource-list
@@ -156,9 +167,9 @@ tabs:
                     version: v1
                     version: v1
                     resource: certificates
                     resource: certificates
       value: |
       value: |
-        .items[] | { 
+        .items[] | {
           metadata: .metadata,
           metadata: .metadata,
-          name: "\(.spec.dnsNames | join(","))", 
+          name: "\(.spec.dnsNames | join(","))",
           label: "\(.metadata.namespace)/\(.metadata.name)",
           label: "\(.metadata.namespace)/\(.metadata.name)",
           status: (
           status: (
             ([.status.conditions[].type] | index("Ready")) as $index | (
             ([.status.conditions[].type] | index("Ready")) as $index | (

+ 1 - 1
api/server/handlers/release/get_steps.go

@@ -50,7 +50,7 @@ func (c *GetReleaseStepsHandler) ServeHTTP(w http.ResponseWriter, r *http.Reques
 	res := make(types.GetReleaseStepsResponse, 0)
 	res := make(types.GetReleaseStepsResponse, 0)
 
 
 	if release.EventContainer != 0 {
 	if release.EventContainer != 0 {
-		subevents, err := c.Repo().Event().ReadEventsByContainerID(release.EventContainer)
+		subevents, err := c.Repo().BuildEvent().ReadEventsByContainerID(release.EventContainer)
 
 
 		if err != nil {
 		if err != nil {
 			c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
 			c.HandleAPIError(w, r, apierrors.NewErrInternal(err))

+ 6 - 5
api/server/handlers/release/ugprade.go

@@ -144,16 +144,17 @@ func (c *UpgradeReleaseHandler) ServeHTTP(w http.ResponseWriter, r *http.Request
 		Name:        helmRelease.Name,
 		Name:        helmRelease.Name,
 		Namespace:   helmRelease.Namespace,
 		Namespace:   helmRelease.Namespace,
 		URL: fmt.Sprintf(
 		URL: fmt.Sprintf(
-			"%s/applications/%s/%s/%s",
+			"%s/applications/%s/%s/%s?project_id=%d",
 			c.Config().ServerConf.ServerURL,
 			c.Config().ServerConf.ServerURL,
 			url.PathEscape(cluster.Name),
 			url.PathEscape(cluster.Name),
-			cluster.Name,
+			helmRelease.Namespace,
 			helmRelease.Name,
 			helmRelease.Name,
-		) + fmt.Sprintf("?project_id=%d", cluster.ProjectID),
+			cluster.ProjectID,
+		),
 	}
 	}
 
 
 	if upgradeErr != nil {
 	if upgradeErr != nil {
-		notifyOpts.Status = slack.StatusFailed
+		notifyOpts.Status = slack.StatusHelmFailed
 		notifyOpts.Info = upgradeErr.Error()
 		notifyOpts.Info = upgradeErr.Error()
 
 
 		notifier.Notify(notifyOpts)
 		notifier.Notify(notifyOpts)
@@ -166,7 +167,7 @@ func (c *UpgradeReleaseHandler) ServeHTTP(w http.ResponseWriter, r *http.Request
 		return
 		return
 	}
 	}
 
 
-	notifyOpts.Status = string(helmRelease.Info.Status)
+	notifyOpts.Status = slack.StatusHelmDeployed
 	notifyOpts.Version = helmRelease.Version
 	notifyOpts.Version = helmRelease.Version
 
 
 	notifier.Notify(notifyOpts)
 	notifier.Notify(notifyOpts)

+ 73 - 0
api/server/handlers/release/update_build_config.go

@@ -0,0 +1,73 @@
+package release
+
+import (
+	"encoding/json"
+	"net/http"
+	"strings"
+
+	"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"
+	"gorm.io/gorm"
+)
+
+type UpdateBuildConfigHandler struct {
+	handlers.PorterHandlerReadWriter
+}
+
+func NewUpdateBuildConfigHandler(
+	config *config.Config,
+	decoderValidator shared.RequestDecoderValidator,
+	writer shared.ResultWriter,
+) *UpdateBuildConfigHandler {
+	return &UpdateBuildConfigHandler{
+		PorterHandlerReadWriter: handlers.NewDefaultPorterHandler(config, decoderValidator, writer),
+	}
+}
+
+func (c *UpdateBuildConfigHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	cluster, _ := r.Context().Value(types.ClusterScope).(*models.Cluster)
+	name, _ := requestutils.GetURLParamString(r, types.URLParamReleaseName)
+	namespace := r.Context().Value(types.NamespaceScope).(string)
+
+	request := &types.UpdateBuildConfigRequest{}
+
+	if ok := c.DecodeAndValidate(w, r, request); !ok {
+		return
+	}
+
+	release, err := c.Repo().Release().ReadRelease(cluster.ID, name, namespace)
+
+	if err != nil {
+		if err == gorm.ErrRecordNotFound {
+			w.WriteHeader(http.StatusNotFound)
+			return
+		}
+
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+	}
+
+	config, err := json.Marshal(request.Config)
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	buildConfig := &models.BuildConfig{
+		Builder:    request.Builder,
+		Buildpacks: strings.Join(request.Buildpacks, ","),
+		Config:     config,
+	}
+
+	buildConfig.ID = release.BuildConfig
+	_, err = c.Repo().BuildConfig().UpdateBuildConfig(buildConfig)
+
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+}

+ 3 - 3
api/server/handlers/release/update_steps.go

@@ -55,7 +55,7 @@ func (c *UpdateReleaseStepsHandler) ServeHTTP(w http.ResponseWriter, r *http.Req
 
 
 	if release.EventContainer == 0 {
 	if release.EventContainer == 0 {
 		// create new event container
 		// create new event container
-		container, err := c.Repo().Event().CreateEventContainer(&models.EventContainer{ReleaseID: release.ID})
+		container, err := c.Repo().BuildEvent().CreateEventContainer(&models.EventContainer{ReleaseID: release.ID})
 		if err != nil {
 		if err != nil {
 			c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
 			c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
 			return
 			return
@@ -72,14 +72,14 @@ func (c *UpdateReleaseStepsHandler) ServeHTTP(w http.ResponseWriter, r *http.Req
 
 
 	}
 	}
 
 
-	container, err := c.Repo().Event().ReadEventContainer(release.EventContainer)
+	container, err := c.Repo().BuildEvent().ReadEventContainer(release.EventContainer)
 
 
 	if err != nil {
 	if err != nil {
 		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
 		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
 		return
 		return
 	}
 	}
 
 
-	if err := c.Repo().Event().AppendEvent(container, &models.SubEvent{
+	if err := c.Repo().BuildEvent().AppendEvent(container, &models.SubEvent{
 		EventContainerID: container.ID,
 		EventContainerID: container.ID,
 		EventID:          request.Event.EventID,
 		EventID:          request.Event.EventID,
 		Name:             request.Event.Name,
 		Name:             request.Event.Name,

+ 5 - 4
api/server/handlers/release/upgrade_webhook.go

@@ -157,18 +157,19 @@ func (c *WebhookHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
 		Name:        rel.Name,
 		Name:        rel.Name,
 		Namespace:   rel.Namespace,
 		Namespace:   rel.Namespace,
 		URL: fmt.Sprintf(
 		URL: fmt.Sprintf(
-			"%s/applications/%s/%s/%s",
+			"%s/applications/%s/%s/%s?project_id=%d",
 			c.Config().ServerConf.ServerURL,
 			c.Config().ServerConf.ServerURL,
 			url.PathEscape(cluster.Name),
 			url.PathEscape(cluster.Name),
 			release.Namespace,
 			release.Namespace,
 			rel.Name,
 			rel.Name,
-		) + fmt.Sprintf("?project_id=%d", release.ProjectID),
+			cluster.ProjectID,
+		),
 	}
 	}
 
 
 	rel, err = helmAgent.UpgradeReleaseByValues(conf, c.Config().DOConf)
 	rel, err = helmAgent.UpgradeReleaseByValues(conf, c.Config().DOConf)
 
 
 	if err != nil {
 	if err != nil {
-		notifyOpts.Status = slack.StatusFailed
+		notifyOpts.Status = slack.StatusHelmFailed
 		notifyOpts.Info = err.Error()
 		notifyOpts.Info = err.Error()
 
 
 		notifier.Notify(notifyOpts)
 		notifier.Notify(notifyOpts)
@@ -181,7 +182,7 @@ func (c *WebhookHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
 		return
 		return
 	}
 	}
 
 
-	notifyOpts.Status = string(rel.Info.Status)
+	notifyOpts.Status = slack.StatusHelmDeployed
 	notifyOpts.Version = rel.Version
 	notifyOpts.Version = rel.Version
 
 
 	notifier.Notify(notifyOpts)
 	notifier.Notify(notifyOpts)

+ 13 - 2
api/server/handlers/user/create.go

@@ -77,14 +77,20 @@ func (u *UserCreateHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
 	}
 	}
 
 
 	// save the user as authenticated in the session
 	// save the user as authenticated in the session
-	if err := authn.SaveUserAuthenticated(w, r, u.Config(), user); err != nil {
+	redirect, err := authn.SaveUserAuthenticated(w, r, u.Config(), user)
+
+	if err != nil {
 		u.HandleAPIError(w, r, apierrors.NewErrInternal(err))
 		u.HandleAPIError(w, r, apierrors.NewErrInternal(err))
 		return
 		return
 	}
 	}
 
 
 	// non-fatal send email verification
 	// non-fatal send email verification
 	if !user.EmailVerified {
 	if !user.EmailVerified {
-		startEmailVerification(u.Config(), w, r, user)
+		err = startEmailVerification(u.Config(), w, r, user)
+
+		if err != nil {
+			u.HandleAPIErrorNoWrite(w, r, apierrors.NewErrInternal(err))
+		}
 	}
 	}
 
 
 	u.Config().AnalyticsClient.Identify(analytics.CreateSegmentIdentifyUser(user))
 	u.Config().AnalyticsClient.Identify(analytics.CreateSegmentIdentifyUser(user))
@@ -94,6 +100,11 @@ func (u *UserCreateHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
 		Email:               user.Email,
 		Email:               user.Email,
 	}))
 	}))
 
 
+	if redirect != "" {
+		http.Redirect(w, r, redirect, http.StatusFound)
+		return
+	}
+
 	u.WriteResult(w, r, user.ToUserType())
 	u.WriteResult(w, r, user.ToUserType())
 }
 }
 
 

+ 11 - 11
api/server/handlers/user/github_callback.go

@@ -78,28 +78,28 @@ func (p *UserOAuthGithubCallbackHandler) ServeHTTP(w http.ResponseWriter, r *htt
 	p.Config().AnalyticsClient.Identify(analytics.CreateSegmentIdentifyUser(user))
 	p.Config().AnalyticsClient.Identify(analytics.CreateSegmentIdentifyUser(user))
 
 
 	// save the user as authenticated in the session
 	// save the user as authenticated in the session
-	if err := authn.SaveUserAuthenticated(w, r, p.Config(), user); err != nil {
+	redirect, err := authn.SaveUserAuthenticated(w, r, p.Config(), user)
+
+	if err != nil {
 		p.HandleAPIError(w, r, apierrors.NewErrInternal(err))
 		p.HandleAPIError(w, r, apierrors.NewErrInternal(err))
 		return
 		return
 	}
 	}
 
 
 	// non-fatal send email verification
 	// non-fatal send email verification
 	if !user.EmailVerified {
 	if !user.EmailVerified {
-		startEmailVerification(p.Config(), w, r, user)
-	}
-
-	if redirectStr, ok := session.Values["redirect_uri"].(string); ok && redirectStr != "" {
-		// attempt to parse the redirect uri, if it fails just redirect to dashboard
-		redirectURI, err := url.Parse(redirectStr)
+		err = startEmailVerification(p.Config(), w, r, user)
 
 
 		if err != nil {
 		if err != nil {
-			http.Redirect(w, r, "/dashboard", 302)
+			p.HandleAPIErrorNoWrite(w, r, apierrors.NewErrInternal(err))
 		}
 		}
+	}
 
 
-		http.Redirect(w, r, fmt.Sprintf("%s?%s", redirectURI.Path, redirectURI.RawQuery), 302)
-	} else {
-		http.Redirect(w, r, "/dashboard", 302)
+	if redirect != "" {
+		http.Redirect(w, r, redirect, http.StatusFound)
+		return
 	}
 	}
+
+	http.Redirect(w, r, "/dashboard", 302)
 }
 }
 
 
 func upsertUserFromToken(config *config.Config, tok *oauth2.Token) (*models.User, error) {
 func upsertUserFromToken(config *config.Config, tok *oauth2.Token) (*models.User, error) {

+ 11 - 11
api/server/handlers/user/google_callback.go

@@ -81,28 +81,28 @@ func (p *UserOAuthGoogleCallbackHandler) ServeHTTP(w http.ResponseWriter, r *htt
 	p.Config().AnalyticsClient.Identify(analytics.CreateSegmentIdentifyUser(user))
 	p.Config().AnalyticsClient.Identify(analytics.CreateSegmentIdentifyUser(user))
 
 
 	// save the user as authenticated in the session
 	// save the user as authenticated in the session
-	if err := authn.SaveUserAuthenticated(w, r, p.Config(), user); err != nil {
+	redirect, err := authn.SaveUserAuthenticated(w, r, p.Config(), user)
+
+	if err != nil {
 		p.HandleAPIError(w, r, apierrors.NewErrInternal(err))
 		p.HandleAPIError(w, r, apierrors.NewErrInternal(err))
 		return
 		return
 	}
 	}
 
 
 	// non-fatal send email verification
 	// non-fatal send email verification
 	if !user.EmailVerified {
 	if !user.EmailVerified {
-		startEmailVerification(p.Config(), w, r, user)
-	}
-
-	if redirectStr, ok := session.Values["redirect_uri"].(string); ok && redirectStr != "" {
-		// attempt to parse the redirect uri, if it fails just redirect to dashboard
-		redirectURI, err := url.Parse(redirectStr)
+		err = startEmailVerification(p.Config(), w, r, user)
 
 
 		if err != nil {
 		if err != nil {
-			http.Redirect(w, r, "/dashboard", 302)
+			p.HandleAPIErrorNoWrite(w, r, apierrors.NewErrInternal(err))
 		}
 		}
+	}
 
 
-		http.Redirect(w, r, fmt.Sprintf("%s?%s", redirectURI.Path, redirectURI.RawQuery), 302)
-	} else {
-		http.Redirect(w, r, "/dashboard", 302)
+	if redirect != "" {
+		http.Redirect(w, r, redirect, http.StatusFound)
+		return
 	}
 	}
+
+	http.Redirect(w, r, "/dashboard", 302)
 }
 }
 
 
 func upsertGoogleUserFromToken(config *config.Config, tok *oauth2.Token) (*models.User, error) {
 func upsertGoogleUserFromToken(config *config.Config, tok *oauth2.Token) (*models.User, error) {

+ 8 - 1
api/server/handlers/user/login.go

@@ -63,11 +63,18 @@ func (u *UserLoginHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
 	}
 	}
 
 
 	// save the user as authenticated in the session
 	// save the user as authenticated in the session
-	if err := authn.SaveUserAuthenticated(w, r, u.Config(), storedUser); err != nil {
+	redirect, err := authn.SaveUserAuthenticated(w, r, u.Config(), storedUser)
+
+	if err != nil {
 		u.HandleAPIError(w, r, apierrors.NewErrInternal(err))
 		u.HandleAPIError(w, r, apierrors.NewErrInternal(err))
 		return
 		return
 	}
 	}
 
 
+	if redirect != "" {
+		http.Redirect(w, r, redirect, http.StatusFound)
+		return
+	}
+
 	u.WriteResult(w, r, storedUser.ToUserType())
 	u.WriteResult(w, r, storedUser.ToUserType())
 }
 }
 
 

+ 200 - 0
api/server/router/cluster.go

@@ -5,6 +5,7 @@ import (
 
 
 	"github.com/go-chi/chi"
 	"github.com/go-chi/chi"
 	"github.com/porter-dev/porter/api/server/handlers/cluster"
 	"github.com/porter-dev/porter/api/server/handlers/cluster"
+	"github.com/porter-dev/porter/api/server/handlers/kube_events"
 	"github.com/porter-dev/porter/api/server/shared"
 	"github.com/porter-dev/porter/api/server/shared"
 	"github.com/porter-dev/porter/api/server/shared/config"
 	"github.com/porter-dev/porter/api/server/shared/config"
 	"github.com/porter-dev/porter/api/types"
 	"github.com/porter-dev/porter/api/types"
@@ -451,6 +452,205 @@ func getClusterRoutes(
 		Router:   r,
 		Router:   r,
 	})
 	})
 
 
+	// GET /api/projects/{project_id}/clusters/{cluster_id}/agent/detect -> cluster.NewDetectAgentInstalledHandler
+	detectAgentInstalledEndpoint := factory.NewAPIEndpoint(
+		&types.APIRequestMetadata{
+			Verb:   types.APIVerbGet,
+			Method: types.HTTPVerbGet,
+			Path: &types.Path{
+				Parent:       basePath,
+				RelativePath: relPath + "/agent/detect",
+			},
+			Scopes: []types.PermissionScope{
+				types.UserScope,
+				types.ProjectScope,
+				types.ClusterScope,
+			},
+		},
+	)
+
+	detectAgentInstalledHandler := cluster.NewDetectAgentInstalledHandler(config)
+
+	routes = append(routes, &Route{
+		Endpoint: detectAgentInstalledEndpoint,
+		Handler:  detectAgentInstalledHandler,
+		Router:   r,
+	})
+
+	// POST /api/projects/{project_id}/clusters/{cluster_id}/agent/install -> cluster.NewInstallAgentHandler
+	installAgentEndpoint := factory.NewAPIEndpoint(
+		&types.APIRequestMetadata{
+			Verb:   types.APIVerbCreate,
+			Method: types.HTTPVerbPost,
+			Path: &types.Path{
+				Parent:       basePath,
+				RelativePath: relPath + "/agent/install",
+			},
+			Scopes: []types.PermissionScope{
+				types.UserScope,
+				types.ProjectScope,
+				types.ClusterScope,
+			},
+		},
+	)
+
+	installAgentHandler := cluster.NewInstallAgentHandler(
+		config,
+		factory.GetDecoderValidator(),
+		factory.GetResultWriter(),
+	)
+
+	routes = append(routes, &Route{
+		Endpoint: installAgentEndpoint,
+		Handler:  installAgentHandler,
+		Router:   r,
+	})
+
+	// GET /api/projects/{project_id}/clusters/{cluster_id}/kube_events -> kube_events.NewGetKubeEventHandler
+	listKubeEventsEndpoint := factory.NewAPIEndpoint(
+		&types.APIRequestMetadata{
+			Verb:   types.APIVerbGet,
+			Method: types.HTTPVerbGet,
+			Path: &types.Path{
+				Parent:       basePath,
+				RelativePath: relPath + "/kube_events",
+			},
+			Scopes: []types.PermissionScope{
+				types.UserScope,
+				types.ProjectScope,
+				types.ClusterScope,
+			},
+		},
+	)
+
+	listKubeEventsHandler := kube_events.NewListKubeEventsHandler(
+		config,
+		factory.GetDecoderValidator(),
+		factory.GetResultWriter(),
+	)
+
+	routes = append(routes, &Route{
+		Endpoint: listKubeEventsEndpoint,
+		Handler:  listKubeEventsHandler,
+		Router:   r,
+	})
+
+	// GET /api/projects/{project_id}/clusters/{cluster_id}/kube_events -> kube_events.NewGetKubeEventHandler
+	getKubeEventEndpoint := factory.NewAPIEndpoint(
+		&types.APIRequestMetadata{
+			Verb:   types.APIVerbGet,
+			Method: types.HTTPVerbGet,
+			Path: &types.Path{
+				Parent:       basePath,
+				RelativePath: fmt.Sprintf("%s/kube_events/{%s}", relPath, types.URLParamKubeEventID),
+			},
+			Scopes: []types.PermissionScope{
+				types.UserScope,
+				types.ProjectScope,
+				types.ClusterScope,
+			},
+		},
+	)
+
+	getKubeEventHandler := kube_events.NewGetKubeEventHandler(
+		config,
+		factory.GetDecoderValidator(),
+		factory.GetResultWriter(),
+	)
+
+	routes = append(routes, &Route{
+		Endpoint: getKubeEventEndpoint,
+		Handler:  getKubeEventHandler,
+		Router:   r,
+	})
+
+	// GET /api/projects/{project_id}/clusters/{cluster_id}/kube_events/{kube_event_id}/logs -> kube_events.NewGetKubeEventLogsHandler
+	getKubeEventLogsEndpoint := factory.NewAPIEndpoint(
+		&types.APIRequestMetadata{
+			Verb:   types.APIVerbGet,
+			Method: types.HTTPVerbGet,
+			Path: &types.Path{
+				Parent:       basePath,
+				RelativePath: fmt.Sprintf("%s/kube_events/{%s}/logs", relPath, types.URLParamKubeEventID),
+			},
+			Scopes: []types.PermissionScope{
+				types.UserScope,
+				types.ProjectScope,
+				types.ClusterScope,
+			},
+		},
+	)
+
+	getKubeEventLogsHandler := kube_events.NewGetKubeEventLogsHandler(
+		config,
+		factory.GetDecoderValidator(),
+		factory.GetResultWriter(),
+	)
+
+	routes = append(routes, &Route{
+		Endpoint: getKubeEventLogsEndpoint,
+		Handler:  getKubeEventLogsHandler,
+		Router:   r,
+	})
+
+	// GET /api/projects/{project_id}/clusters/{cluster_id}/kube_events/{kube_event_id}/log_buckets -> kube_events.NewGetKubeEventLogBucketsHandler
+	getKubeEventLogBucketsEndpoint := factory.NewAPIEndpoint(
+		&types.APIRequestMetadata{
+			Verb:   types.APIVerbGet,
+			Method: types.HTTPVerbGet,
+			Path: &types.Path{
+				Parent:       basePath,
+				RelativePath: fmt.Sprintf("%s/kube_events/{%s}/log_buckets", relPath, types.URLParamKubeEventID),
+			},
+			Scopes: []types.PermissionScope{
+				types.UserScope,
+				types.ProjectScope,
+				types.ClusterScope,
+			},
+		},
+	)
+
+	getKubeEventLogBucketsHandler := kube_events.NewGetKubeEventLogBucketsHandler(
+		config,
+		factory.GetDecoderValidator(),
+		factory.GetResultWriter(),
+	)
+
+	routes = append(routes, &Route{
+		Endpoint: getKubeEventLogBucketsEndpoint,
+		Handler:  getKubeEventLogBucketsHandler,
+		Router:   r,
+	})
+
+	// POST /api/projects/{project_id}/clusters/{cluster_id}/kube_events -> kube_events.NewCreateKubeEventHandler
+	createKubeEventsEndpoint := factory.NewAPIEndpoint(
+		&types.APIRequestMetadata{
+			Verb:   types.APIVerbCreate,
+			Method: types.HTTPVerbPost,
+			Path: &types.Path{
+				Parent:       basePath,
+				RelativePath: relPath + "/kube_events",
+			},
+			Scopes: []types.PermissionScope{
+				types.UserScope,
+				types.ProjectScope,
+				types.ClusterScope,
+			},
+		},
+	)
+
+	createKubeEventsHandler := kube_events.NewCreateKubeEventHandler(
+		config,
+		factory.GetDecoderValidator(),
+		factory.GetResultWriter(),
+	)
+
+	routes = append(routes, &Route{
+		Endpoint: createKubeEventsEndpoint,
+		Handler:  createKubeEventsHandler,
+		Router:   r,
+	})
+
 	// GET /api/projects/{project_id}/clusters/{cluster_id}/prometheus/ingresses -> cluster.NewListNGINXIngressesHandler
 	// GET /api/projects/{project_id}/clusters/{cluster_id}/prometheus/ingresses -> cluster.NewListNGINXIngressesHandler
 	listNGINXIngressesEndpoint := factory.NewAPIEndpoint(
 	listNGINXIngressesEndpoint := factory.NewAPIEndpoint(
 		&types.APIRequestMetadata{
 		&types.APIRequestMetadata{

+ 33 - 0
api/server/router/namespace.go

@@ -425,6 +425,39 @@ func getNamespaceRoutes(
 		Router:   r,
 		Router:   r,
 	})
 	})
 
 
+	// GET /api/projects/{project_id}/clusters/{cluster_id}/namespaces/{namespace}/pods/{name} -> namespace.NewGetPodHandler
+	getPodEndpoint := factory.NewAPIEndpoint(
+		&types.APIRequestMetadata{
+			Verb:   types.APIVerbGet,
+			Method: types.HTTPVerbGet,
+			Path: &types.Path{
+				Parent: basePath,
+				RelativePath: fmt.Sprintf(
+					"%s/pods/{%s}",
+					relPath,
+					types.URLParamPodName,
+				),
+			},
+			Scopes: []types.PermissionScope{
+				types.UserScope,
+				types.ProjectScope,
+				types.ClusterScope,
+				types.NamespaceScope,
+			},
+		},
+	)
+
+	getPodHandler := namespace.NewGetPodHandler(
+		config,
+		factory.GetResultWriter(),
+	)
+
+	routes = append(routes, &Route{
+		Endpoint: getPodEndpoint,
+		Handler:  getPodHandler,
+		Router:   r,
+	})
+
 	// DELETE /api/projects/{project_id}/clusters/{cluster_id}/namespaces/{namespace}/pods/{name} -> namespace.NewDeletePodHandler
 	// DELETE /api/projects/{project_id}/clusters/{cluster_id}/namespaces/{namespace}/pods/{name} -> namespace.NewDeletePodHandler
 	deletePodEndpoint := factory.NewAPIEndpoint(
 	deletePodEndpoint := factory.NewAPIEndpoint(
 		&types.APIRequestMetadata{
 		&types.APIRequestMetadata{

+ 30 - 0
api/server/router/release.go

@@ -293,6 +293,36 @@ func getReleaseRoutes(
 		Router:   r,
 		Router:   r,
 	})
 	})
 
 
+	// POST /api/projects/{project_id}/clusters/{cluster_id}/namespaces/{namespace}/releases/{name}/buildconfig -> release.NewUpdateBuildConfigHandler
+	updateBuildConfigEndpoint := factory.NewAPIEndpoint(
+		&types.APIRequestMetadata{
+			Verb:   types.APIVerbUpdate,
+			Method: types.HTTPVerbPost,
+			Path: &types.Path{
+				Parent:       basePath,
+				RelativePath: "/releases/{name}/buildconfig",
+			},
+			Scopes: []types.PermissionScope{
+				types.UserScope,
+				types.ProjectScope,
+				types.ClusterScope,
+				types.NamespaceScope,
+			},
+		},
+	)
+
+	updateBuildConfigHandler := release.NewUpdateBuildConfigHandler(
+		config,
+		factory.GetDecoderValidator(),
+		factory.GetResultWriter(),
+	)
+
+	routes = append(routes, &Route{
+		Endpoint: updateBuildConfigEndpoint,
+		Handler:  updateBuildConfigHandler,
+		Router:   r,
+	})
+
 	// GET /api/projects/{project_id}/clusters/{cluster_id}/namespaces/{namespace}/releases/{name}/webhook -> release.NewGetWebhookHandler
 	// GET /api/projects/{project_id}/clusters/{cluster_id}/namespaces/{namespace}/releases/{name}/webhook -> release.NewGetWebhookHandler
 	getWebhookEndpoint := factory.NewAPIEndpoint(
 	getWebhookEndpoint := factory.NewAPIEndpoint(
 		&types.APIRequestMetadata{
 		&types.APIRequestMetadata{

+ 7 - 0
api/server/router/router.go

@@ -7,6 +7,7 @@ import (
 	"strings"
 	"strings"
 
 
 	"github.com/go-chi/chi"
 	"github.com/go-chi/chi"
+	chiMiddleware "github.com/go-chi/chi/middleware"
 	"github.com/porter-dev/porter/api/server/authn"
 	"github.com/porter-dev/porter/api/server/authn"
 	"github.com/porter-dev/porter/api/server/authz"
 	"github.com/porter-dev/porter/api/server/authz"
 	"github.com/porter-dev/porter/api/server/authz/policy"
 	"github.com/porter-dev/porter/api/server/authz/policy"
@@ -50,6 +51,10 @@ func NewAPIRouter(config *config.Config) *chi.Mux {
 	userRegisterer := NewUserScopedRegisterer(projRegisterer)
 	userRegisterer := NewUserScopedRegisterer(projRegisterer)
 	panicMW := middleware.NewPanicMiddleware(config)
 	panicMW := middleware.NewPanicMiddleware(config)
 
 
+	if config.ServerConf.PprofEnabled {
+		r.Mount("/debug", chiMiddleware.Profiler())
+	}
+
 	r.Route("/api", func(r chi.Router) {
 	r.Route("/api", func(r chi.Router) {
 		// set panic middleware for all API endpoints to catch panics
 		// set panic middleware for all API endpoints to catch panics
 		r.Use(panicMW.Middleware)
 		r.Use(panicMW.Middleware)
@@ -103,6 +108,8 @@ func NewAPIRouter(config *config.Config) *chi.Mux {
 	fs := http.FileServer(http.Dir(staticFilePath))
 	fs := http.FileServer(http.Dir(staticFilePath))
 
 
 	r.Get("/*", func(w http.ResponseWriter, r *http.Request) {
 	r.Get("/*", func(w http.ResponseWriter, r *http.Request) {
+		w.Header().Set("X-Frame-Options", "DENY")
+
 		if _, err := os.Stat(staticFilePath + r.RequestURI); os.IsNotExist(err) {
 		if _, err := os.Stat(staticFilePath + r.RequestURI); os.IsNotExist(err) {
 			w.Header().Set("Cache-Control", "no-cache")
 			w.Header().Set("Cache-Control", "no-cache")
 
 

+ 3 - 0
api/server/shared/config/env/envconfs.go

@@ -85,6 +85,9 @@ type ServerConf struct {
 
 
 	// Token for internal retool to authenticate to internal API endpoints
 	// Token for internal retool to authenticate to internal API endpoints
 	RetoolToken string `env:"RETOOL_TOKEN"`
 	RetoolToken string `env:"RETOOL_TOKEN"`
+
+	// Enable pprof profiling endpoints
+	PprofEnabled bool `env:"PPROF_ENABLED,default=false"`
 }
 }
 
 
 // DBConf is the database configuration: if generated from environment variables,
 // DBConf is the database configuration: if generated from environment variables,

+ 20 - 0
api/types/build_config.go

@@ -0,0 +1,20 @@
+package types
+
+// BuildConfig
+type BuildConfig struct {
+	Builder    string   `json:"builder"`
+	Buildpacks []string `json:"buildpacks"`
+	Config     []byte   `json:"config"`
+}
+
+type CreateBuildConfigRequest struct {
+	Builder    string                 `json:"builder" form:"required"`
+	Buildpacks []string               `json:"buildpacks"`
+	Config     map[string]interface{} `json:"config,omitempty"`
+}
+
+type UpdateBuildConfigRequest struct {
+	Builder    string                 `json:"builder"`
+	Buildpacks []string               `json:"buildpacks"`
+	Config     map[string]interface{} `json:"config,omitempty"`
+}

+ 0 - 5
api/types/git_installation.go

@@ -39,11 +39,6 @@ type GetBuildpackRequest struct {
 	GithubDirectoryRequest
 	GithubDirectoryRequest
 }
 }
 
 
-type GetBuildpackResponse struct {
-	Valid bool   `json:"valid"`
-	Name  string `json:"name"`
-}
-
 type GetContentsRequest struct {
 type GetContentsRequest struct {
 	GithubDirectoryRequest
 	GithubDirectoryRequest
 }
 }

+ 91 - 0
api/types/kube_events.go

@@ -0,0 +1,91 @@
+package types
+
+import "time"
+
+const (
+	URLParamKubeEventID = "kube_event_id"
+)
+
+type KubeEventType string
+
+const (
+	KubeEventTypeCritical KubeEventType = "critical"
+	KubeEventTypeNormal   KubeEventType = "normal"
+)
+
+type GroupOptions struct {
+	ResourceType  string
+	Name          string
+	Namespace     string
+	ThresholdTime time.Time
+}
+
+// CreateKubeEventRequest is the type for creating a new kube event
+type CreateKubeEventRequest struct {
+	ResourceType string        `json:"resource_type" form:"required"`
+	Name         string        `json:"name" form:"required"`
+	OwnerType    string        `json:"owner_type"`
+	OwnerName    string        `json:"owner_name"`
+	EventType    KubeEventType `json:"event_type" form:"required"`
+	Namespace    string        `json:"namespace"`
+	Message      string        `json:"message" form:"required"`
+	Reason       string        `json:"reason"`
+	Timestamp    time.Time     `json:"timestamp" form:"required"`
+}
+
+type KubeEvent struct {
+	CreatedAt time.Time `json:"created_at"`
+	UpdatedAt time.Time `json:"updated_at"`
+
+	ID        uint `json:"id"`
+	ProjectID uint `json:"project_id"`
+	ClusterID uint `json:"cluster_id"`
+
+	ResourceType string `json:"resource_type"`
+	Name         string `json:"name"`
+	OwnerType    string `json:"owner_type"`
+	OwnerName    string `json:"owner_name"`
+	Namespace    string `json:"namespace"`
+
+	SubEvents []*KubeSubEvent `json:"sub_events"`
+}
+
+type KubeSubEvent struct {
+	EventType KubeEventType `json:"event_type"`
+	Message   string        `json:"message"`
+	Reason    string        `json:"reason"`
+	Timestamp time.Time     `json:"timestamp"`
+}
+
+type ListKubeEventRequest struct {
+	Limit int `schema:"limit"`
+	Skip  int `schema:"skip"`
+
+	// can only be "timestamp" for now
+	SortBy string `schema:"sort_by"`
+
+	OwnerType string `schema:"owner_type"`
+	OwnerName string `schema:"owner_name"`
+
+	ResourceType string `schema:"resource_type"`
+}
+
+type ListKubeEventsResponse struct {
+	Count int64 `json:"count"`
+	Limit int   `json:"limit"`
+	Skip  int   `json:"skip"`
+
+	KubeEvents []*KubeEvent `json:"kube_events"`
+}
+
+type GetKubeEventLogsRequest struct {
+	Timestamp int `schema:"timestamp"`
+}
+
+type GetKubeEventLogsResponse struct {
+	Logs []string `json:"logs"`
+}
+
+type GetKubeEventLogBucketsResponse struct {
+	LogBuckets []string `json:"log_buckets"`
+}

+ 4 - 0
api/types/release.go

@@ -20,6 +20,7 @@ type PorterRelease struct {
 	LatestVersion   string           `json:"latest_version"`
 	LatestVersion   string           `json:"latest_version"`
 	GitActionConfig *GitActionConfig `json:"git_action_config,omitempty"`
 	GitActionConfig *GitActionConfig `json:"git_action_config,omitempty"`
 	ImageRepoURI    string           `json:"image_repo_uri"`
 	ImageRepoURI    string           `json:"image_repo_uri"`
+	BuildConfig     *BuildConfig     `json:"build_config,omitempty"`
 }
 }
 
 
 type GetReleaseResponse Release
 type GetReleaseResponse Release
@@ -45,6 +46,7 @@ type CreateReleaseRequest struct {
 
 
 	ImageURL           string                        `json:"image_url" form:"required"`
 	ImageURL           string                        `json:"image_url" form:"required"`
 	GithubActionConfig *CreateGitActionConfigRequest `json:"github_action_config,omitempty"`
 	GithubActionConfig *CreateGitActionConfigRequest `json:"github_action_config,omitempty"`
+	BuildConfig        *CreateBuildConfigRequest     `json:"build_config,omitempty"`
 }
 }
 
 
 type CreateAddonRequest struct {
 type CreateAddonRequest struct {
@@ -114,6 +116,8 @@ type NotificationConfig struct {
 	Enabled bool `json:"enabled"`
 	Enabled bool `json:"enabled"`
 	Success bool `json:"success"`
 	Success bool `json:"success"`
 	Failure bool `json:"failure"`
 	Failure bool `json:"failure"`
+
+	NotifLimit string `json:"notif_limit"`
 }
 }
 
 
 type GetNotificationConfigResponse struct {
 type GetNotificationConfigResponse struct {

+ 5 - 2
cli/cmd/deploy/build.go

@@ -7,6 +7,7 @@ import (
 	"strings"
 	"strings"
 
 
 	api "github.com/porter-dev/porter/api/client"
 	api "github.com/porter-dev/porter/api/client"
+	"github.com/porter-dev/porter/api/types"
 	"github.com/porter-dev/porter/cli/cmd/docker"
 	"github.com/porter-dev/porter/cli/cmd/docker"
 	"github.com/porter-dev/porter/cli/cmd/pack"
 	"github.com/porter-dev/porter/cli/cmd/pack"
 )
 )
@@ -28,6 +29,7 @@ func (b *BuildAgent) BuildDocker(
 	buildCtx,
 	buildCtx,
 	dockerfilePath,
 	dockerfilePath,
 	tag string,
 	tag string,
+	currentTag string,
 ) error {
 ) error {
 	buildCtx, dockerfilePath, isDockerfileInCtx, err := ResolveDockerPaths(
 	buildCtx, dockerfilePath, isDockerfileInCtx, err := ResolveDockerPaths(
 		basePath,
 		basePath,
@@ -42,6 +44,7 @@ func (b *BuildAgent) BuildDocker(
 	opts := &docker.BuildOpts{
 	opts := &docker.BuildOpts{
 		ImageRepo:         b.imageRepo,
 		ImageRepo:         b.imageRepo,
 		Tag:               tag,
 		Tag:               tag,
+		CurrentTag:        currentTag,
 		BuildContext:      buildCtx,
 		BuildContext:      buildCtx,
 		Env:               b.env,
 		Env:               b.env,
 		DockerfilePath:    dockerfilePath,
 		DockerfilePath:    dockerfilePath,
@@ -54,7 +57,7 @@ func (b *BuildAgent) BuildDocker(
 }
 }
 
 
 // BuildPack uses the cloud-native buildpack client to build a container image
 // BuildPack uses the cloud-native buildpack client to build a container image
-func (b *BuildAgent) BuildPack(dockerAgent *docker.Agent, dst, tag string) error {
+func (b *BuildAgent) BuildPack(dockerAgent *docker.Agent, dst, tag string, buildConfig *types.BuildConfig) error {
 	// retag the image with "pack-cache" tag so that it doesn't re-pull from the registry
 	// retag the image with "pack-cache" tag so that it doesn't re-pull from the registry
 	if b.imageExists {
 	if b.imageExists {
 		err := dockerAgent.TagImage(
 		err := dockerAgent.TagImage(
@@ -81,7 +84,7 @@ func (b *BuildAgent) BuildPack(dockerAgent *docker.Agent, dst, tag string) error
 	}
 	}
 
 
 	// call builder
 	// call builder
-	err := packAgent.Build(opts)
+	err := packAgent.Build(opts, buildConfig)
 
 
 	if err != nil {
 	if err != nil {
 		return err
 		return err

+ 2 - 2
cli/cmd/deploy/create.go

@@ -281,9 +281,9 @@ func (c *CreateAgent) CreateFromDocker(
 	}
 	}
 
 
 	if opts.Method == DeployBuildTypeDocker {
 	if opts.Method == DeployBuildTypeDocker {
-		err = buildAgent.BuildDocker(agent, opts.LocalPath, opts.LocalPath, opts.LocalDockerfile, "latest")
+		err = buildAgent.BuildDocker(agent, opts.LocalPath, opts.LocalPath, opts.LocalDockerfile, "latest", "")
 	} else {
 	} else {
-		err = buildAgent.BuildPack(agent, opts.LocalPath, "latest")
+		err = buildAgent.BuildPack(agent, opts.LocalPath, "latest", nil)
 	}
 	}
 
 
 	if err != nil {
 	if err != nil {

+ 7 - 4
cli/cmd/deploy/deploy.go

@@ -238,10 +238,12 @@ func (d *DeployAgent) Build() error {
 		}
 		}
 	}
 	}
 
 
-	if d.tag == "" {
-		currImageSection := d.release.Config["image"].(map[string]interface{})
+	// retrieve current image to use for cache
+	currImageSection := d.release.Config["image"].(map[string]interface{})
+	currentTag := currImageSection["tag"].(string)
 
 
-		d.tag = currImageSection["tag"].(string)
+	if d.tag == "" {
+		d.tag = currentTag
 	}
 	}
 
 
 	err = d.pullCurrentReleaseImage()
 	err = d.pullCurrentReleaseImage()
@@ -269,10 +271,11 @@ func (d *DeployAgent) Build() error {
 			buildCtx,
 			buildCtx,
 			d.dockerfilePath,
 			d.dockerfilePath,
 			d.tag,
 			d.tag,
+			currentTag,
 		)
 		)
 	}
 	}
 
 
-	return buildAgent.BuildPack(d.agent, buildCtx, d.tag)
+	return buildAgent.BuildPack(d.agent, buildCtx, d.tag, d.release.BuildConfig)
 }
 }
 
 
 // Push pushes a local image to the remote repository linked in the release
 // Push pushes a local image to the remote repository linked in the release

+ 1 - 0
cli/cmd/docker/agent.go

@@ -243,6 +243,7 @@ func (a *Agent) getPullOptions(image string) (types.ImagePullOptions, error) {
 
 
 	return types.ImagePullOptions{
 	return types.ImagePullOptions{
 		RegistryAuth: authConfigEncoded,
 		RegistryAuth: authConfigEncoded,
+		Platform:     "linux/amd64",
 	}, nil
 	}, nil
 }
 }
 
 

+ 6 - 1
cli/cmd/docker/builder.go

@@ -21,6 +21,7 @@ import (
 type BuildOpts struct {
 type BuildOpts struct {
 	ImageRepo         string
 	ImageRepo         string
 	Tag               string
 	Tag               string
+	CurrentTag        string
 	BuildContext      string
 	BuildContext      string
 	DockerfilePath    string
 	DockerfilePath    string
 	IsDockerfileInCtx bool
 	IsDockerfileInCtx bool
@@ -67,7 +68,11 @@ func (a *Agent) BuildLocal(opts *BuildOpts) error {
 		Tags: []string{
 		Tags: []string{
 			fmt.Sprintf("%s:%s", opts.ImageRepo, opts.Tag),
 			fmt.Sprintf("%s:%s", opts.ImageRepo, opts.Tag),
 		},
 		},
-		Remove: true,
+		CacheFrom: []string{
+			fmt.Sprintf("%s:%s", opts.ImageRepo, opts.CurrentTag),
+		},
+		Remove:   true,
+		Platform: "linux/amd64",
 	})
 	})
 
 
 	if err != nil {
 	if err != nil {

+ 75 - 0
cli/cmd/pack/logger.go

@@ -0,0 +1,75 @@
+package pack
+
+import (
+	"fmt"
+	"io"
+	"log"
+	"os"
+	"strings"
+
+	"github.com/buildpacks/pack/logging"
+)
+
+type packLogger struct {
+	out *log.Logger
+}
+
+// Replicate the exact behavior of https://github.com/buildpacks/pack/blob/main/pkg/logging/logger_simple.go
+func newPackLogger() logging.Logger {
+	return &packLogger{
+		out: log.New(os.Stderr, "", log.LstdFlags|log.Lmicroseconds),
+	}
+}
+
+const (
+	debugPrefix = "DEBUG:"
+	infoPrefix  = "INFO:"
+	warnPrefix  = "WARN:"
+	errorPrefix = "ERROR:"
+	prefixFmt   = "%-7s %s"
+)
+
+func (l *packLogger) Debug(msg string) {
+	l.out.Printf(prefixFmt, debugPrefix, msg)
+}
+
+func (l *packLogger) Debugf(format string, v ...interface{}) {
+	// We do not want to print the environment variables for now as they might
+	// contain sensitive information like client IDs and secrets
+	// Refer: https://github.com/buildpacks/pack/blob/main/internal/builder/builder.go#L349
+	if !strings.HasPrefix(format, "Provided Environment Variables") {
+		l.out.Printf(prefixFmt, debugPrefix, fmt.Sprintf(format, v...))
+	}
+}
+
+func (l *packLogger) Info(msg string) {
+	l.out.Printf(prefixFmt, infoPrefix, msg)
+}
+
+func (l *packLogger) Infof(format string, v ...interface{}) {
+	l.out.Printf(prefixFmt, infoPrefix, fmt.Sprintf(format, v...))
+}
+
+func (l *packLogger) Warn(msg string) {
+	l.out.Printf(prefixFmt, warnPrefix, msg)
+}
+
+func (l *packLogger) Warnf(format string, v ...interface{}) {
+	l.out.Printf(prefixFmt, warnPrefix, fmt.Sprintf(format, v...))
+}
+
+func (l *packLogger) Error(msg string) {
+	l.out.Printf(prefixFmt, errorPrefix, msg)
+}
+
+func (l *packLogger) Errorf(format string, v ...interface{}) {
+	l.out.Printf(prefixFmt, errorPrefix, fmt.Sprintf(format, v...))
+}
+
+func (l *packLogger) Writer() io.Writer {
+	return l.out.Writer()
+}
+
+func (l *packLogger) IsVerbose() bool {
+	return false
+}

+ 16 - 2
cli/cmd/pack/pack.go

@@ -4,19 +4,21 @@ import (
 	"context"
 	"context"
 	"fmt"
 	"fmt"
 	"path/filepath"
 	"path/filepath"
+	"strings"
 
 
 	"github.com/buildpacks/pack"
 	"github.com/buildpacks/pack"
+	"github.com/porter-dev/porter/api/types"
 	"github.com/porter-dev/porter/cli/cmd/docker"
 	"github.com/porter-dev/porter/cli/cmd/docker"
 )
 )
 
 
 type Agent struct{}
 type Agent struct{}
 
 
-func (a *Agent) Build(opts *docker.BuildOpts) error {
+func (a *Agent) Build(opts *docker.BuildOpts, buildConfig *types.BuildConfig) error {
 	//create a context object
 	//create a context object
 	context := context.Background()
 	context := context.Background()
 
 
 	//initialize a pack client
 	//initialize a pack client
-	client, err := pack.NewClient()
+	client, err := pack.NewClient(pack.WithLogger(newPackLogger()))
 
 
 	if err != nil {
 	if err != nil {
 		return err
 		return err
@@ -37,5 +39,17 @@ func (a *Agent) Build(opts *docker.BuildOpts) error {
 		Env:             opts.Env,
 		Env:             opts.Env,
 	}
 	}
 
 
+	if buildConfig != nil {
+		buildOpts.Builder = buildConfig.Builder
+		if len(buildConfig.Buildpacks) > 0 {
+			buildOpts.Buildpacks = buildConfig.Buildpacks
+		}
+		// FIXME: use all the config vars
+	}
+
+	if strings.HasPrefix(buildOpts.Builder, "heroku") {
+		buildOpts.Buildpacks = append(buildOpts.Buildpacks, "heroku/procfile")
+	}
+
 	return client.Build(context, buildOpts)
 	return client.Build(context, buildOpts)
 }
 }

+ 1 - 1
cli/cmd/run.go

@@ -325,7 +325,7 @@ func executeRunEphemeral(config *PorterRunSharedConfig, namespace, name, contain
 	req := config.RestClient.Post().
 	req := config.RestClient.Post().
 		Resource("pods").
 		Resource("pods").
 		Name(podName).
 		Name(podName).
-		Namespace("default").
+		Namespace(namespace).
 		SubResource("attach")
 		SubResource("attach")
 
 
 	req.Param("stdin", "true")
 	req.Param("stdin", "true")

Разлика између датотеке није приказан због своје велике величине
+ 122 - 874
dashboard/package-lock.json


+ 1 - 0
dashboard/package.json

@@ -39,6 +39,7 @@
     "react-ace": "^9.1.3",
     "react-ace": "^9.1.3",
     "react-dom": "^16.13.1",
     "react-dom": "^16.13.1",
     "react-error-boundary": "^3.1.3",
     "react-error-boundary": "^3.1.3",
+    "react-infinite-scroll-component": "^6.1.0",
     "react-modal": "^3.11.2",
     "react-modal": "^3.11.2",
     "react-router-dom": "^5.2.0",
     "react-router-dom": "^5.2.0",
     "react-table": "^7.7.0",
     "react-table": "^7.7.0",

+ 157 - 0
dashboard/src/components/Dropdown.tsx

@@ -0,0 +1,157 @@
+import React, { useState } from "react";
+import styled from "styled-components";
+
+type Option = {
+  value: unknown;
+  label: string;
+};
+
+type DropdownProps = {
+  options: Array<Option>;
+  selectedOption: Option;
+  onSelect?: (selectedOption: Option) => unknown;
+  selectLabel?: (currentLabel: string) => void;
+  selectValue?: (currentValue: any) => void;
+};
+
+const Dropdown: React.FunctionComponent<DropdownProps> = ({
+  options,
+  selectedOption,
+  selectLabel,
+  selectValue,
+  onSelect,
+}) => {
+  const [isDropdownExpanded, setIsDropdownExpanded] = useState(false);
+
+  const handleSelectOption = (option: Option) => {
+    if (selectedOption.label === option.label) {
+      return;
+    }
+    onSelect(option);
+    typeof selectLabel === "function" && selectLabel(option.label);
+    typeof selectValue === "function" && selectValue(option.value);
+  };
+
+  const renderDropdown = () => {
+    if (isDropdownExpanded) {
+      return (
+        <>
+          <DropdownOverlay onClick={() => setIsDropdownExpanded(false)} />
+          <OptionWrapper
+            dropdownWidth="230px"
+            dropdownMaxHeight="200px"
+            onClick={() => setIsDropdownExpanded(false)}
+          >
+            {renderOptionList()}
+          </OptionWrapper>
+        </>
+      );
+    }
+  };
+
+  const renderOptionList = () => {
+    return options.map((option, i, originalArray) => {
+      return (
+        <Option
+          key={i}
+          selected={option.label === selectedOption.label}
+          onClick={() => handleSelectOption(option)}
+          lastItem={i === originalArray.length - 1}
+        >
+          {option.label}
+        </Option>
+      );
+    });
+  };
+
+  return (
+    <DropdownSelector
+      onClick={() => setIsDropdownExpanded(!isDropdownExpanded)}
+    >
+      <DropdownLabel>{selectedOption?.label}</DropdownLabel>
+      <i className="material-icons">arrow_drop_down</i>
+      {renderDropdown()}
+    </DropdownSelector>
+  );
+};
+
+export default Dropdown;
+
+const DropdownSelector = styled.div`
+  font-size: 13px;
+  font-weight: 500;
+  position: relative;
+  color: #ffffff;
+  display: flex;
+  align-items: center;
+  cursor: pointer;
+  border-radius: 5px;
+  :hover {
+    > i {
+      background: #ffffff22;
+    }
+  }
+
+  > i {
+    border-radius: 20px;
+    font-size: 20px;
+    margin-left: 10px;
+  }
+`;
+
+const DropdownLabel = styled.div`
+  white-space: nowrap;
+  text-overflow: ellipsis;
+  overflow: hidden;
+  max-width: 200px;
+`;
+
+const DropdownOverlay = styled.div`
+  position: fixed;
+  width: 100%;
+  height: 100%;
+  z-index: 10;
+  left: 0px;
+  top: 0px;
+  cursor: default;
+`;
+
+const OptionWrapper = styled.div`
+  position: absolute;
+  left: 0;
+  top: calc(100% + 10px);
+  background: #26282f;
+  width: ${(props: { dropdownWidth: string; dropdownMaxHeight: string }) =>
+    props.dropdownWidth};
+  max-height: ${(props: { dropdownWidth: string; dropdownMaxHeight: string }) =>
+    props.dropdownMaxHeight || "300px"};
+  border-radius: 3px;
+  z-index: 999;
+  overflow-y: auto;
+  margin-bottom: 20px;
+  box-shadow: 0px 4px 10px 0px #00000088;
+`;
+
+const Option = styled.div`
+  width: 100%;
+  border-top: 1px solid #00000000;
+  border-bottom: 1px solid
+    ${(props: { selected: boolean; lastItem: boolean }) =>
+      props.lastItem ? "#ffffff00" : "#ffffff15"};
+  height: 37px;
+  font-size: 13px;
+  padding-top: 9px;
+  align-items: center;
+  padding-left: 15px;
+  cursor: pointer;
+  padding-right: 10px;
+  white-space: nowrap;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  background: ${(props: { selected: boolean; lastItem: boolean }) =>
+    props.selected ? "#ffffff11" : ""};
+
+  :hover {
+    background: #ffffff22;
+  }
+`;

+ 1 - 0
dashboard/src/components/ResourceTab.tsx

@@ -142,6 +142,7 @@ export default class ResourceTab extends Component<PropsType, StateType> {
 const StyledResourceTab = styled.div`
 const StyledResourceTab = styled.div`
   width: 100%;
   width: 100%;
   margin-bottom: 2px;
   margin-bottom: 2px;
+  font-size: 13px;
   background: #ffffff11;
   background: #ffffff11;
   border-bottom-left-radius: ${(props: {
   border-bottom-left-radius: ${(props: {
     isLast: boolean;
     isLast: boolean;

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

@@ -77,7 +77,7 @@ const SaveButton: React.FC<Props> = (props) => {
         rounded={props.rounded}
         rounded={props.rounded}
         disabled={props.disabled}
         disabled={props.disabled}
         onClick={props.onClick}
         onClick={props.onClick}
-        color={props.color || "#616FEEcc"}
+        color={props.color || "#5561C0"}
       >
       >
         {props.children || props.text}
         {props.children || props.text}
       </Button>
       </Button>

+ 53 - 5
dashboard/src/components/Selector.tsx

@@ -16,6 +16,7 @@ type PropsType = {
   closeOverlay?: boolean;
   closeOverlay?: boolean;
   placeholder?: string;
   placeholder?: string;
   scrollBuffer?: boolean;
   scrollBuffer?: boolean;
+  disableTooltip?: boolean;
 };
 };
 
 
 type StateType = {};
 type StateType = {};
@@ -23,6 +24,7 @@ type StateType = {};
 export default class Selector extends Component<PropsType, StateType> {
 export default class Selector extends Component<PropsType, StateType> {
   state = {
   state = {
     expanded: false,
     expanded: false,
+    showTooltip: false,
   };
   };
 
 
   wrapperRef: any = React.createRef();
   wrapperRef: any = React.createRef();
@@ -169,6 +171,8 @@ export default class Selector extends Component<PropsType, StateType> {
           expanded={this.state.expanded}
           expanded={this.state.expanded}
           width={this.props.width}
           width={this.props.width}
           height={this.props.height}
           height={this.props.height}
+          onMouseEnter={() => this.setState({ showTooltip: true })}
+          onMouseLeave={() => this.setState({ showTooltip: false })}
         >
         >
           <Flex>
           <Flex>
             {this.renderIcon()}
             {this.renderIcon()}
@@ -182,6 +186,15 @@ export default class Selector extends Component<PropsType, StateType> {
           </Flex>
           </Flex>
           <i className="material-icons">arrow_drop_down</i>
           <i className="material-icons">arrow_drop_down</i>
         </MainSelector>
         </MainSelector>
+        {!this.props.disableTooltip && this.state.showTooltip && (
+          <Tooltip>
+            {activeValue
+              ? activeValue === ""
+                ? "All"
+                : this.getLabel(activeValue)
+              : this.props.placeholder}
+          </Tooltip>
+        )}
         {this.renderDropdown()}
         {this.renderDropdown()}
       </StyledSelector>
       </StyledSelector>
     );
     );
@@ -206,6 +219,7 @@ const ScrollBuffer = styled.div`
 const Flex = styled.div`
 const Flex = styled.div`
   display: flex;
   display: flex;
   align-items: center;
   align-items: center;
+  width: 85%;
 `;
 `;
 
 
 const Icon = styled.div`
 const Icon = styled.div`
@@ -263,16 +277,18 @@ const NewOption = styled.div`
   }
   }
 `;
 `;
 
 
-const Option = styled.div<{
+type OptionProps = {
   selected: boolean;
   selected: boolean;
   lastItem: boolean;
   lastItem: boolean;
   height: string;
   height: string;
-}>`
+};
+
+const Option = styled.div`
   width: 100%;
   width: 100%;
   border-top: 1px solid #00000000;
   border-top: 1px solid #00000000;
   border-bottom: 1px solid
   border-bottom: 1px solid
-    ${(props) => (props.lastItem ? "#ffffff00" : "#ffffff15")};
-  height: ${(props) => props.height || "37px"};
+    ${(props: OptionProps) => (props.lastItem ? "#ffffff00" : "#ffffff15")};
+  height: ${(props: OptionProps) => props.height || "37px"};
   font-size: 13px;
   font-size: 13px;
   align-items: center;
   align-items: center;
   display: flex;
   display: flex;
@@ -283,7 +299,7 @@ const Option = styled.div<{
   white-space: nowrap;
   white-space: nowrap;
   overflow: hidden;
   overflow: hidden;
   text-overflow: ellipsis;
   text-overflow: ellipsis;
-  background: ${(props) => (props.selected ? "#ffffff11" : "")};
+  background: ${(props: OptionProps) => (props.selected ? "#ffffff11" : "")};
 
 
   :hover {
   :hover {
     background: #ffffff22;
     background: #ffffff22;
@@ -353,3 +369,35 @@ const MainSelector = styled.div`
     }) => (props.expanded ? "rotate(180deg)" : "")};
     }) => (props.expanded ? "rotate(180deg)" : "")};
   }
   }
 `;
 `;
+
+const Tooltip = styled.div`
+  position: absolute;
+  left: 5px;
+  word-wrap: break-word;
+  top: 40px;
+  min-height: 18px;
+  width: fit-content;
+  padding: 5px 7px;
+  background: #272731;
+  z-index: 999;
+  display: flex;
+  flex-direction: column;
+  justify-content: center;
+  flex: 1;
+  color: white;
+  text-transform: none;
+  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;
+    }
+  }
+`;

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

@@ -15,7 +15,7 @@ const UnexpectedErrorPage = ({ error, resetErrorBoundary }: any) => (
         </BackButton>
         </BackButton>
         <Splitter>|</Splitter>
         <Splitter>|</Splitter>
         <Helper>
         <Helper>
-          Sorry for the inconvinience! The Porter team has been notified
+          Sorry for the inconvenience! The Porter team has been notified.
         </Helper>
         </Helper>
       </Flex>
       </Flex>
     </StyledPageNotFound>
     </StyledPageNotFound>

+ 202 - 0
dashboard/src/components/events/EventCard.tsx

@@ -0,0 +1,202 @@
+import React, { useState } from "react";
+import styled from "styled-components";
+
+type CardProps = {
+  event: any;
+  selectEvent?: (event: any) => void;
+  overrideName?: string;
+};
+
+export const getReadableDate = (s: string) => {
+  let ts = new Date(s);
+  let date = ts.toLocaleDateString();
+  let time = ts.toLocaleTimeString([], {
+    hour: "numeric",
+    minute: "2-digit",
+  });
+  return `${time} ${date}`;
+};
+
+// Rename to Event Card
+const EventCard: React.FunctionComponent<CardProps> = ({
+  event,
+  selectEvent,
+  overrideName,
+}) => {
+  const [showTooltip, setShowTooltip] = useState(false);
+  return (
+    <>
+      <StyledCard
+        onClick={() => selectEvent(event)}
+        status={event.event_type.toLowerCase()}
+      >
+        <ContentContainer>
+          <Icon
+            status={event.event_type.toLowerCase() as any}
+            className="material-icons-outlined"
+          >
+            {event.event_type === "critical" ? "report_problem" : "info"}
+          </Icon>
+          <EventInformation>
+            <EventName>
+              <Helper>{event.resource_type}:</Helper>
+              {event.name}
+            </EventName>
+            <EventReason>{event.last_message}</EventReason>
+          </EventInformation>
+        </ContentContainer>
+        <ActionContainer>
+          <TimestampContainer>
+            <TimestampIcon className="material-icons-outlined">
+              access_time
+            </TimestampIcon>
+            <span>{getReadableDate(event.timestamp)}</span>
+          </TimestampContainer>
+        </ActionContainer>
+      </StyledCard>
+    </>
+  );
+};
+
+export default EventCard;
+
+const StyledCard = styled.div<{ status: string }>`
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  border: 1px solid
+    ${({ status }) => (status === "critical" ? "#ff385d" : "#ffffff44")};
+  background: #ffffff08;
+  margin-bottom: 5px;
+  border-radius: 10px;
+  padding: 14px;
+  overflow: hidden;
+  height: 80px;
+  font-size: 13px;
+  cursor: pointer;
+  :hover {
+    background: #ffffff11;
+    border: 1px solid
+      ${({ status }) => (status === "critical" ? "#ff385d" : "#ffffff66")};
+  }
+  animation: fadeIn 0.5s;
+  @keyframes fadeIn {
+    from {
+      opacity: 0;
+    }
+    to {
+      opacity: 1;
+    }
+  }
+`;
+
+const ContentContainer = styled.div`
+  display: flex;
+  height: 100%;
+  width: 100%;
+  align-items: center;
+`;
+
+const Icon = styled.span<{ status: "critical" | "normal" }>`
+  font-size: 20px;
+  margin-left: 10px;
+  margin-right: 20px;
+  color: ${({ status }) => (status === "critical" ? "#ff385d" : "#aaaabb")};
+`;
+
+const EventInformation = styled.div`
+  display: flex;
+  flex-direction: column;
+  justify-content: space-around;
+  height: 100%;
+`;
+
+const EventName = styled.div`
+  font-family: "Work Sans", sans-serif;
+  font-weight: 500;
+  color: #ffffff;
+`;
+
+const Helper = styled.span`
+  text-transform: capitalize;
+  color: #ffffff44;
+  margin-right: 5px;
+`;
+
+const EventReason = styled.div`
+  font-family: "Work Sans", sans-serif;
+  color: #aaaabb;
+  margin-top: 5px;
+`;
+
+const ActionContainer = styled.div`
+  display: flex;
+  align-items: center;
+  white-space: nowrap;
+  height: 100%;
+`;
+
+const HistoryButton = styled.button`
+  position: relative;
+  border: none;
+  background: none;
+  color: white;
+  padding: 5px;
+  display: flex;
+  justify-content: center;
+  align-items: center;
+  border-radius: 50%;
+  color: #ffffff44;
+  :hover {
+    background: #32343a;
+    cursor: pointer;
+  }
+`;
+
+const Tooltip = styled.div`
+  position: absolute;
+  left: 0px;
+  word-wrap: break-word;
+  top: 38px;
+  min-height: 18px;
+  padding: 5px 7px;
+  background: #272731;
+  z-index: 999;
+  display: flex;
+  flex-direction: column;
+  justify-content: center;
+  flex: 1;
+  color: white;
+  text-transform: none;
+  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;
+    }
+  }
+`;
+
+const TimestampContainer = styled.div`
+  display: flex;
+  white-space: nowrap;
+  align-items: center;
+  justify-self: flex-end;
+  color: #ffffff55;
+  margin-right: 10px;
+  font-size: 13px;
+  min-width: 130px;
+  justify-content: space-between;
+`;
+
+const TimestampIcon = styled.span`
+  margin-right: 7px;
+  font-size: 18px;
+`;

+ 360 - 0
dashboard/src/components/events/SubEventsList.tsx

@@ -0,0 +1,360 @@
+import React, { useContext, useEffect, useMemo, useState } from "react";
+import styled from "styled-components";
+import api from "shared/api";
+import { Context } from "shared/Context";
+import SubEventCard from "./sub-events/SubEventCard";
+import Loading from "components/Loading";
+import LogBucketCard from "./sub-events/LogBucketCard";
+import useLastSeenPodStatus from "./useLastSeenPodStatus";
+
+const getReadableDate = (s: number) => {
+  let ts = new Date(s);
+  let date = ts.toLocaleDateString();
+  let time = ts.toLocaleTimeString([], {
+    hour: "numeric",
+    minute: "2-digit",
+  });
+  return `${time} ${date}`;
+};
+
+const SubEventsList: React.FC<{
+  clearSelectedEvent: () => void;
+  event: any;
+  enableTopMargin?: boolean;
+}> = ({ event, clearSelectedEvent, enableTopMargin }) => {
+  const { currentProject, currentCluster } = useContext(Context);
+  const {
+    status,
+    hasError: hasPodStatusErrored,
+    isLoading: isPodStatusLoading,
+  } = useLastSeenPodStatus({
+    podName: event.name,
+    namespace: event.namespace,
+    resource_type: event.resource_type,
+  });
+  const [isLoading, setIsLoading] = useState(true);
+  const [subEvents, setSubEvents] = useState(null);
+
+  const getData = async () => {
+    const project_id = currentProject?.id;
+    const cluster_id = currentCluster?.id;
+    const kube_event_id = event?.id;
+    let updatedEvent: any = null;
+    try {
+      updatedEvent = await api
+        .getKubeEvent("<token>", {}, { project_id, cluster_id, kube_event_id })
+        .then((res) => res?.data);
+    } catch (error) {
+      console.error(error);
+    }
+
+    let logBucketsParsed = [];
+    try {
+      const logBucketsData = await api
+        .getLogBuckets("token", {}, { project_id, cluster_id, kube_event_id })
+        .then((res) => res?.data);
+
+      logBucketsParsed = logBucketsData.log_buckets.map((bucket: string) => {
+        const [
+          _resourceType,
+          _namespace,
+          resource_name,
+          timestamp,
+        ] = bucket.split(":");
+        return {
+          event_type: "log_bucket",
+          resource_name,
+          timestamp: new Date(Number(timestamp) * 1000).toUTCString(),
+          parent_id: updatedEvent?.id,
+        };
+      });
+    } catch (error) {
+      console.error(error);
+    }
+
+    const subEventsSorted = (updatedEvent.sub_events as any[])
+      .map((s: any) => ({
+        ...s,
+        timestamp: new Date(s.timestamp).getTime(),
+      }))
+      .sort((prev: any, next: any) => next.timestamp - prev.timestamp);
+
+    const firstEvent = subEventsSorted.shift();
+    const lastEvent = subEventsSorted.pop();
+
+    const filteredLogBuckets = (logBucketsParsed as any[]).filter((bucket) => {
+      const bucketTime = new Date(bucket.timestamp).getTime();
+      return (
+        bucketTime >= lastEvent.timestamp && bucketTime <= firstEvent.timestamp
+      );
+    });
+
+    setSubEvents([...updatedEvent.sub_events, ...filteredLogBuckets]);
+    setIsLoading(false);
+  };
+
+  useEffect(() => {
+    getData();
+  }, [event, currentCluster, currentProject]);
+
+  const sortedSubEvents = useMemo(() => {
+    if (!Array.isArray(subEvents)) {
+      return [];
+    }
+    return subEvents
+      .map((s) => ({
+        ...s,
+        timestamp: new Date(s.timestamp).getTime(),
+      }))
+      .sort((prev, next) => next.timestamp - prev.timestamp)
+      .map((s) => ({
+        ...s,
+        timestamp: new Date(s.timestamp).toUTCString(),
+      }));
+  }, [subEvents]);
+
+  return (
+    <>
+      <Timeline enableTopMargin={enableTopMargin}>
+        <ControlRow>
+          <BackButton onClick={clearSelectedEvent}>
+            <i className="material-icons">close</i>
+          </BackButton>
+          <Icon
+            status={event.event_type.toLowerCase() as any}
+            className="material-icons-outlined"
+          >
+            {event.event_type === "critical" ? "report_problem" : "info"}
+          </Icon>
+          <div>
+            Pod {event.name} crashed
+            {event?.resource_type?.toLowerCase() === "pod" && (
+              <StyledHelper>
+                {hasPodStatusErrored ? (
+                  "We couldn't retrieve last pod status, please try again later"
+                ) : (
+                  <>
+                    {isPodStatusLoading ? (
+                      "Loading last seen pod status"
+                    ) : (
+                      <>
+                        Last seen pod status: {status}{" "}
+                        <StatusColor
+                          status={status?.toLowerCase()}
+                        ></StatusColor>
+                      </>
+                    )}
+                  </>
+                )}
+              </StyledHelper>
+            )}
+          </div>
+        </ControlRow>
+        {isLoading ? (
+          <Placeholder>
+            <Loading />
+          </Placeholder>
+        ) : sortedSubEvents?.length ? (
+          <EventsGrid>
+            <Rail />
+            {sortedSubEvents.map((subEvent: any, i: number) => {
+              if (subEvent?.event_type === "log_bucket") {
+                return (
+                  <Wrapper>
+                    <TimelineNode>
+                      <Penumbra>
+                        <Circle />
+                      </Penumbra>
+                      {getReadableDate(subEvent.timestamp)}
+                    </TimelineNode>
+                    <LogBucketCard logEvent={subEvent} />
+                    {i === sortedSubEvents.length - 1 && <RailCover />}
+                  </Wrapper>
+                );
+              }
+              return (
+                <Wrapper>
+                  <TimelineNode>
+                    <Penumbra>
+                      <Circle />
+                    </Penumbra>
+                    {getReadableDate(subEvent.timestamp)}
+                  </TimelineNode>
+                  <SubEventCard subEvent={subEvent} />
+                  {i === sortedSubEvents.length - 1 && <RailCover />}
+                </Wrapper>
+              );
+            })}
+          </EventsGrid>
+        ) : (
+          <Placeholder>
+            <i className="material-icons">search</i>
+            No sub-events were found.
+          </Placeholder>
+        )}
+      </Timeline>
+    </>
+  );
+};
+
+export default SubEventsList;
+
+const StyledHelper = styled.div`
+  color: #aaaabb;
+  line-height: 1.6em;
+  font-size: 13px;
+`;
+
+const Placeholder = styled.div`
+  padding: 30px;
+  padding-bottom: 40px;
+  font-size: 13px;
+  color: #ffffff44;
+  min-height: 340px;
+  margin-top: 20px;
+  background: #ffffff08;
+  height: calc(50vh - 60px);
+  border-radius: 8px;
+  width: 100%;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+
+  > i {
+    font-size: 18px;
+    margin-right: 8px;
+  }
+`;
+
+const RailCover = styled.div`
+  background: #202227;
+  height: 100%;
+  width: 35px;
+  position: absolute;
+  top: 20px;
+  left: 0;
+`;
+
+const Penumbra = styled.div`
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  background: #202227;
+  padding: 8px;
+  border-radius: 30px;
+  margin-right: 4px;
+`;
+
+const TimelineNode = styled.div`
+  position: absolute;
+  top: 0;
+  left: 7px;
+  display: flex;
+  align-items: center;
+  color: #aaaabb;
+  font-size: 13px;
+`;
+
+const Circle = styled.div`
+  width: 7px;
+  height: 7px;
+  border-radius: 20px;
+  background: #aaaabb;
+`;
+
+const Wrapper = styled.div`
+  position: relative;
+  width: 100%;
+  padding-top: 35px;
+  padding-left: 35px;
+`;
+
+const Rail = styled.div`
+  position: absolute;
+  top: -8px;
+  left: 17px;
+  width: 3px;
+  height: 100%;
+  z-index: -1;
+  background: #36383d;
+`;
+
+const Timeline = styled.div`
+  margin-top: ${(props: { enableTopMargin: boolean }) =>
+    props.enableTopMargin ? "30px" : "unset"};
+  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 Icon = styled.span<{ status: "critical" | "normal" }>`
+  font-size: 26px;
+  margin-left: 17px;
+  margin-right: 10px;
+  color: ${({ status }) => (status === "critical" ? "#ff385d" : "#aaaabb")};
+`;
+
+const ControlRow = styled.div`
+  display: flex;
+  justify-content: flex-start;
+  align-items: center;
+  margin-bottom: 15px;
+  padding-left: 0px;
+  font-weight: 500;
+`;
+
+const BackButton = styled.div`
+  display: flex;
+  width: 37px;
+  z-index: 1;
+  cursor: pointer;
+  height: 37px;
+  align-items: center;
+  justify-content: center;
+  border: 1px solid #ffffff55;
+  border-radius: 100px;
+  background: #ffffff11;
+
+  > i {
+    font-size: 20px;
+  }
+
+  :hover {
+    background: #ffffff22;
+    > img {
+      opacity: 1;
+    }
+  }
+`;
+
+const EventsGrid = styled.div`
+  position: relative;
+  padding-top: 9px;
+`;
+
+const StatusColor = styled.div`
+  display: inline-block;
+  margin-right: 7px;
+  width: 7px;
+  min-width: 7px;
+  height: 7px;
+  background: ${(props: { status: string }) =>
+    props.status === "running"
+      ? "#4797ff"
+      : props.status === "failed" || props.status === "deleted"
+      ? "#ed5f85"
+      : props.status === "completed"
+      ? "#00d12a"
+      : "#f5cb42"};
+  border-radius: 20px;
+`;

+ 166 - 0
dashboard/src/components/events/sub-events/LogBucketCard.tsx

@@ -0,0 +1,166 @@
+import React, { useContext, useEffect, useState } from "react";
+import api from "shared/api";
+import { Context } from "shared/Context";
+import styled, { keyframes } from "styled-components";
+
+type LogBucketCardProps = {
+  logEvent: any;
+};
+
+const LogBucketCard: React.FunctionComponent<LogBucketCardProps> = ({
+  logEvent,
+}) => {
+  const { currentProject, currentCluster } = useContext(Context);
+  const [isLoading, setIsLoading] = useState(true);
+  const [isExpanded, setIsExpanded] = useState(false);
+  const [logs, setLogs] = useState([]);
+
+  const getLogsForBucket = async () => {
+    const project_id = currentProject?.id;
+    const cluster_id = currentCluster?.id;
+    const kube_event_id = logEvent?.parent_id;
+    const timestamp = logEvent?.timestamp;
+    try {
+      const logsData = await api
+        .getLogBucketLogs(
+          "<token>",
+          { timestamp: new Date(timestamp).getTime() },
+          { project_id, cluster_id, kube_event_id }
+        )
+        .then((res) => res?.data);
+
+      if (!Array.isArray(logsData.logs)) {
+        setLogs([]);
+        setIsLoading(false);
+        return;
+      }
+
+      const filteredLogs = logsData.logs.filter((log: string | unknown) => {
+        if (typeof log === "string") {
+          return log.length;
+        }
+        return false;
+      });
+      setLogs(filteredLogs);
+      setIsLoading(false);
+    } catch (error) {
+      console.error(error);
+    }
+  };
+
+  useEffect(() => {
+    if (!isExpanded) {
+      return;
+    }
+
+    if (!Array.isArray(logs) || !logs.length) {
+      getLogsForBucket();
+    }
+  }, [currentProject, currentCluster, logEvent, isExpanded]);
+
+  return (
+    <StyledCard>
+      <FlexCenter expandLogs={isExpanded}>
+        <ShowLogsButton
+          onClick={() => setIsExpanded((prevIsExpanded) => !prevIsExpanded)}
+        >
+          {isExpanded ? "Hide logs" : "Display logs"}
+          <ButtonIcon className="material-icons">
+            {isExpanded ? "arrow_drop_up" : "arrow_drop_down"}
+          </ButtonIcon>
+        </ShowLogsButton>
+      </FlexCenter>
+      {isExpanded && (
+        <>
+          {/* Case: Is still getting logs and user triggered expanded */}
+          {isLoading && <Loading>Loading . . .</Loading>}
+          {/* Case: No logs found after the api call */}
+          {!isLoading && !logs?.length && <Loading>No logs found.</Loading>}
+          {/* Case: Logs were found successfully  */}
+          {!isLoading && !!logs?.length && logs?.map((l) => <Log>{l}</Log>)}
+        </>
+      )}
+    </StyledCard>
+  );
+};
+
+export default LogBucketCard;
+
+const Loading = styled.div`
+  margin-top: 5px;
+  margin-left: 5px;
+`;
+
+const Log = styled.div`
+  font-family: monospace, sans-serif;
+  font-size: 12px;
+  color: white;
+`;
+
+const FlexCenter = styled.div`
+  display: flex;
+  justify-content: center;
+  align-items: center;
+  ${(props: { expandLogs: boolean }) => {
+    if (!props.expandLogs) {
+      return "";
+    }
+
+    return `
+      border-bottom: solid 1px;
+      padding-bottom: 15px;
+      margin-bottom: 15px;
+      border-color: #515256;
+    `;
+  }}
+  transition-property: all;
+  transition-duration: 0.5s;
+  transition-timing-function: cubic-bezier(0, 1, 0.5, 1);
+`;
+
+const fadeInKeyframe = keyframes`
+  from {
+    opacity: 0;
+  }
+  to {
+    opacity: 1;
+  }
+`;
+
+const StyledCard = styled.div`
+  border: 1px solid #ffffff44;
+  margin-bottom: 30px;
+  border-radius: 10px;
+  padding: 14px;
+  padding-left: 13px;
+  font-size: 13px;
+  background: #121318;
+  user-select: text;
+  overflow-wrap: break-word;
+  overflow-y: auto;
+  min-height: 55px;
+  color: #aaaabb;
+
+  animation: ${fadeInKeyframe} 0.5s;
+`;
+
+const ShowLogsButton = styled.button`
+  border: solid 1px;
+  border-radius: 10px;
+  border-color: #515256;
+  color: white;
+  background: none;
+  padding: 8px 12px 8px 20px;
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+
+  :hover {
+    cursor: pointer;
+    background: #5152569c;
+  }
+`;
+
+const ButtonIcon = styled.i`
+  padding-left: 5px;
+`;

+ 57 - 0
dashboard/src/components/events/sub-events/SubEventCard.tsx

@@ -0,0 +1,57 @@
+import React, { useState } from "react";
+import styled from "styled-components";
+
+type CardProps = {
+  subEvent: any;
+};
+
+const SubEventCard: React.FunctionComponent<CardProps> = ({ subEvent }) => {
+  return (
+    <StyledCard status={subEvent.event_type.toLowerCase()}>
+      <Icon
+        status={subEvent.event_type.toLowerCase() as any}
+        className="material-icons-outlined"
+      >
+        {subEvent.event_type.toLowerCase() === "critical"
+          ? "report_problem"
+          : "info"}
+      </Icon>
+      {subEvent.message}
+    </StyledCard>
+  );
+};
+
+export default SubEventCard;
+
+const StyledCard = styled.div<{ status: string }>`
+  display: flex;
+  align-items: center;
+  justify-content: flex-start;
+  border: 1px solid
+    ${({ status }) => (status === "critical" ? "#ff385d" : "#ffffff44")};
+  background: #ffffff08;
+  margin-bottom: 30px;
+  border-radius: 10px;
+  padding: 14px;
+  padding-left: 13px;
+  overflow: hidden;
+  min-height: 55px;
+  font-size: 13px;
+  color: #aaaabb;
+  animation: fadeIn 0.5s;
+  @keyframes fadeIn {
+    from {
+      opacity: 0;
+    }
+    to {
+      opacity: 1;
+    }
+  }
+`;
+
+const Icon = styled.span<{ status: "critical" | "normal" }>`
+  font-size: 20px;
+  margin-left: 10px;
+  margin-right: 13px;
+  color: ${({ status }) => (status === "critical" ? "#ff385d" : "#aaaabb")};
+`;

+ 214 - 0
dashboard/src/components/events/useEvents.ts

@@ -0,0 +1,214 @@
+import { unionBy } from "lodash";
+import { useContext, useEffect, useMemo, useState } from "react";
+import api from "shared/api";
+import { Context } from "shared/Context";
+import { KubeEvent } from "shared/types";
+
+type UseKubeEventsProps = {
+  resourceType: "NODE" | "POD" | "HPA";
+  ownerName?: string;
+  ownerType?: string;
+  shouldWaitForOwner?: boolean;
+};
+
+export const useKubeEvents = ({
+  resourceType,
+  ownerName,
+  ownerType,
+  shouldWaitForOwner,
+}: UseKubeEventsProps) => {
+  const { currentCluster, currentProject } = useContext(Context);
+  const [hasPorterAgent, setHasPorterAgent] = useState(false);
+
+  const [isLoading, setIsLoading] = useState(true);
+  const [kubeEvents, setKubeEvents] = useState<KubeEvent[]>([]);
+  const [hasMore, setHasMore] = useState(true);
+  const [totalCount, setTotalCount] = useState(0);
+
+  // Check if the porter agent is installed or not
+  useEffect(() => {
+    let isSubscribed = true;
+
+    const project_id = currentProject?.id;
+    const cluster_id = currentCluster?.id;
+
+    api
+      .detectPorterAgent("<token>", {}, { project_id, cluster_id })
+      .then(() => {
+        setHasPorterAgent(true);
+      })
+      .catch(() => {
+        setHasPorterAgent(false);
+        setIsLoading(false);
+      });
+
+    return () => {
+      isSubscribed = false;
+    };
+  }, [currentProject, currentCluster]);
+
+  // Get events
+  useEffect(() => {
+    let isSubscribed = true;
+
+    if (shouldWaitForOwner && !ownerName?.length && !ownerType?.length) {
+      return () => {
+        isSubscribed = false;
+      };
+    }
+
+    if (hasPorterAgent) {
+      fetchData(true).then(() => {
+        if (isSubscribed) {
+          setIsLoading(false);
+        }
+      });
+    }
+
+    return () => {
+      isSubscribed = false;
+    };
+  }, [
+    currentProject?.id,
+    currentCluster?.id,
+    hasPorterAgent,
+    resourceType,
+    ownerType,
+    ownerName,
+  ]);
+
+  const fetchData = async (clear?: boolean) => {
+    const project_id = currentProject?.id;
+    const cluster_id = currentCluster?.id;
+    let skipBy;
+    if (!clear) {
+      skipBy = kubeEvents?.length;
+    } else {
+      setHasMore(true);
+    }
+
+    const type = resourceType;
+
+    try {
+      const data = await api
+        .getKubeEvents(
+          "<token>",
+          {
+            skip: skipBy,
+            resource_type: type,
+            owner_name: ownerName,
+            owner_type: ownerType,
+          },
+          { project_id, cluster_id }
+        )
+        .then((res) => res.data);
+
+      const newKubeEvents = data?.kube_events;
+      const totalCount = data?.count;
+
+      setTotalCount(totalCount);
+
+      if (!newKubeEvents?.length) {
+        setHasMore(false);
+        return;
+      }
+
+      if (clear) {
+        setKubeEvents(newKubeEvents);
+
+        if (totalCount === newKubeEvents.length) {
+          setHasMore(false);
+        } else {
+          setHasMore(true);
+        }
+
+        return;
+      }
+
+      const newEvents = unionBy(kubeEvents, newKubeEvents, "id");
+
+      if (totalCount === newEvents.length) {
+        setHasMore(false);
+      } else {
+        setHasMore(true);
+      }
+
+      setKubeEvents(newEvents);
+    } catch (error) {
+      console.log(error);
+    }
+  };
+
+  const installPorterAgent = () => {
+    const project_id = currentProject?.id;
+    const cluster_id = currentCluster?.id;
+
+    api
+      .installPorterAgent("<token>", {}, { project_id, cluster_id })
+      .then(() => {
+        setHasPorterAgent(true);
+      })
+      .catch(() => {
+        setHasPorterAgent(false);
+      });
+  };
+
+  const getLastSubEvent = (
+    subEvents: {
+      event_type: string;
+      message: string;
+      reason: string;
+      timestamp: string;
+    }[]
+  ) => {
+    const sortedEvents = subEvents
+      .map((s) => {
+        return {
+          ...s,
+          timestamp: new Date(s.timestamp).getTime(),
+        };
+      })
+      .sort((prev, next) => next.timestamp - prev.timestamp);
+
+    return sortedEvents[0];
+  };
+
+  // Fill up the data missing on events with the subevents
+  const processedKubeEvents = useMemo(() => {
+    return kubeEvents
+      .filter((event) => {
+        if (
+          !Array.isArray(event?.sub_events) ||
+          event.sub_events.length === 0
+        ) {
+          return false;
+        }
+        return true;
+      })
+      .map((e: any) => {
+        const lastSubEvent = getLastSubEvent(e.sub_events);
+
+        return {
+          ...e,
+          event_type: lastSubEvent.event_type,
+          timestamp: new Date(lastSubEvent.timestamp).getTime(),
+          last_message: lastSubEvent.message,
+        };
+      })
+      .sort((prev, next) => next.timestamp - prev.timestamp)
+      .map((s) => ({
+        ...s,
+        timestamp: new Date(s.timestamp).toUTCString(),
+      }));
+  }, [kubeEvents]);
+
+  return {
+    hasPorterAgent,
+    isLoading,
+    kubeEvents: processedKubeEvents,
+    hasMore,
+    totalCount,
+    loadMoreEvents: () => fetchData(),
+    triggerInstall: installPorterAgent,
+  };
+};

+ 89 - 0
dashboard/src/components/events/useLastSeenPodStatus.ts

@@ -0,0 +1,89 @@
+import { useContext, useEffect, useState } from "react";
+import api from "shared/api";
+import { Context } from "shared/Context";
+
+const useLastSeenPodStatus = ({
+  podName,
+  namespace,
+  resource_type,
+}: {
+  podName: string;
+  namespace: string;
+  resource_type: string;
+}) => {
+  const [status, setCurrentStatus] = useState(null);
+  const [isLoading, setIsLoading] = useState(true);
+  const [hasError, setHasError] = useState(false);
+  const { currentProject, currentCluster } = useContext(Context);
+
+  const getPodStatus = (status: any) => {
+    if (
+      status?.phase === "Pending" &&
+      status?.containerStatuses !== undefined
+    ) {
+      return status.containerStatuses[0].state.waiting.reason;
+    } else if (status?.phase === "Pending") {
+      return "Pending";
+    }
+
+    if (status?.phase === "Failed") {
+      return "failed";
+    }
+
+    if (status?.phase === "Running") {
+      let collatedStatus = "running";
+
+      status?.containerStatuses?.forEach((s: any) => {
+        if (s.state?.waiting) {
+          collatedStatus =
+            s.state?.waiting.reason === "CrashLoopBackOff"
+              ? "failed"
+              : "waiting";
+        } else if (s.state?.terminated) {
+          collatedStatus = "failed";
+        }
+      });
+      return collatedStatus;
+    }
+  };
+
+  const updatePods = async () => {
+    try {
+      const res = await api.getPodByName(
+        "<token>",
+        {},
+        {
+          project_id: currentProject.id,
+          cluster_id: currentCluster.id,
+          namespace: "default",
+          name: podName,
+        }
+      );
+      console.log(getPodStatus(res.data.status));
+
+      setCurrentStatus(getPodStatus(res.data.status));
+    } catch (error) {
+      if (error?.response?.status === 404) {
+        setCurrentStatus("Deleted");
+      } else {
+        setHasError(true);
+      }
+    } finally {
+      setIsLoading(false);
+    }
+  };
+
+  useEffect(() => {
+    if (resource_type?.toLowerCase() === "pod") {
+      updatePods();
+    }
+  }, [podName, namespace, resource_type]);
+
+  return {
+    status,
+    isLoading,
+    hasError,
+  };
+};
+
+export default useLastSeenPodStatus;

+ 4 - 3
dashboard/src/components/image-selector/ImageList.tsx

@@ -280,17 +280,18 @@ const BackButton = styled.div`
   }
   }
 `;
 `;
 
 
-const ImageItem = styled.div<{ lastItem: boolean, isSelected: boolean }>`
+const ImageItem = styled.div<{ lastItem: boolean; isSelected: boolean }>`
   display: flex;
   display: flex;
   width: 100%;
   width: 100%;
   font-size: 13px;
   font-size: 13px;
-  border-bottom: 1px solid ${props => props.lastItem ? "#00000000" : "#606166"};
+  border-bottom: 1px solid
+    ${(props) => (props.lastItem ? "#00000000" : "#606166")};
   color: #ffffff;
   color: #ffffff;
   user-select: none;
   user-select: none;
   align-items: center;
   align-items: center;
   padding: 10px 0px;
   padding: 10px 0px;
   cursor: pointer;
   cursor: pointer;
-  background: ${props => props.isSelected ? "#ffffff11" : ""};
+  background: ${(props) => (props.isSelected ? "#ffffff11" : "")};
   :hover {
   :hover {
     background: #ffffff22;
     background: #ffffff22;
 
 

+ 2 - 2
dashboard/src/components/image-selector/TagList.tsx

@@ -174,13 +174,13 @@ const TagName = styled.div<{ lastItem?: boolean; isSelected?: boolean }>`
   width: 100%;
   width: 100%;
   font-size: 13px;
   font-size: 13px;
   border-bottom: 1px solid
   border-bottom: 1px solid
-    ${props => props.lastItem ? "#00000000" : "#606166"};
+    ${(props) => (props.lastItem ? "#00000000" : "#606166")};
   color: #ffffff;
   color: #ffffff;
   user-select: none;
   user-select: none;
   align-items: center;
   align-items: center;
   padding: 10px 0px;
   padding: 10px 0px;
   cursor: pointer;
   cursor: pointer;
-  background: ${props => props.isSelected ? "#ffffff11" : ""};
+  background: ${(props) => (props.isSelected ? "#ffffff11" : "")};
   :hover {
   :hover {
     background: #ffffff22;
     background: #ffffff22;
 
 

+ 9 - 2
dashboard/src/components/porter-form/field-components/ResourceList.tsx

@@ -27,8 +27,15 @@ const ResourceList: React.FC<ResourceListField> = (props) => {
   };
   };
 
 
   useEffect(() => {
   useEffect(() => {
+    if (
+      !formState?.variables?.currentChart?.name ||
+      !formState?.variables?.namespace
+    ) {
+      return () => {};
+    }
+
     let { group, version, resource } = props.context.config;
     let { group, version, resource } = props.context.config;
-    let apiEndpoint = `/api/projects/${currentProject.id}/clusters/${currentCluster.id}/namespaces/${formState.variables.namespace}/releases/${formState.variables.currentChart.name}/0/form_stream?`;
+    let apiEndpoint = `/api/projects/${currentProject.id}/clusters/${currentCluster.id}/namespaces/${formState?.variables?.namespace}/releases/${formState?.variables?.currentChart?.name}/0/form_stream?`;
     apiEndpoint += `resource=${resource}&group=${group}&version=${version}`;
     apiEndpoint += `resource=${resource}&group=${group}&version=${version}`;
 
 
     const wsConfig = {
     const wsConfig = {
@@ -81,7 +88,7 @@ const ResourceList: React.FC<ResourceListField> = (props) => {
     return () => {
     return () => {
       closeAllWebsockets();
       closeAllWebsockets();
     };
     };
-  }, []);
+  }, [formState?.variables?.currentChart, formState?.variables?.namespace]);
 
 
   return (
   return (
     <ResourceListWrapper>
     <ResourceListWrapper>

+ 8 - 8
dashboard/src/components/porter-form/types.ts

@@ -41,16 +41,16 @@ export interface ResourceListField extends GenericField {
   value: any[];
   value: any[];
   context?: {
   context?: {
     config?: {
     config?: {
-      group: string
-      version: string
-      resource: string
-    }
-  },
+      group: string;
+      version: string;
+      resource: string;
+    };
+  };
   settings?: {
   settings?: {
     options?: {
     options?: {
-      "resource-button": any,
-    }
-  }
+      "resource-button": any;
+    };
+  };
 }
 }
 
 
 export interface VeleroBackupField extends GenericField {
 export interface VeleroBackupField extends GenericField {

+ 2 - 0
dashboard/src/components/repo-selector/ActionConfEditor.tsx

@@ -24,6 +24,7 @@ type Props = {
   setFolderPath: (x: string) => void;
   setFolderPath: (x: string) => void;
   setSelectedRegistry: (x: any) => void;
   setSelectedRegistry: (x: any) => void;
   selectedRegistry: any;
   selectedRegistry: any;
+  setBuildConfig: (x: any) => void;
 };
 };
 
 
 const defaultActionConfig: ActionConfigType = {
 const defaultActionConfig: ActionConfigType = {
@@ -121,6 +122,7 @@ const ActionConfEditor: React.FC<Props> = (props) => {
       folderPath={props.folderPath}
       folderPath={props.folderPath}
       setSelectedRegistry={props.setSelectedRegistry}
       setSelectedRegistry={props.setSelectedRegistry}
       selectedRegistry={props.selectedRegistry}
       selectedRegistry={props.selectedRegistry}
+      setBuildConfig={props.setBuildConfig}
     />
     />
   );
   );
 };
 };

+ 512 - 135
dashboard/src/components/repo-selector/ActionDetails.tsx

@@ -1,5 +1,11 @@
-import React, { Component } from "react";
-import styled from "styled-components";
+import React, {
+  Component,
+  useContext,
+  useEffect,
+  useMemo,
+  useState,
+} from "react";
+import styled, { keyframes } from "styled-components";
 
 
 import { integrationList } from "shared/common";
 import { integrationList } from "shared/common";
 import { Context } from "shared/Context";
 import { Context } from "shared/Context";
@@ -7,6 +13,10 @@ import api from "shared/api";
 import Loading from "components/Loading";
 import Loading from "components/Loading";
 import { ActionConfigType } from "../../shared/types";
 import { ActionConfigType } from "../../shared/types";
 import InputRow from "../form-components/InputRow";
 import InputRow from "../form-components/InputRow";
+import Selector from "components/Selector";
+import Heading from "components/form-components/Heading";
+import Helper from "components/form-components/Helper";
+import SelectRow from "components/form-components/SelectRow";
 
 
 type PropsType = {
 type PropsType = {
   actionConfig: ActionConfigType | null;
   actionConfig: ActionConfigType | null;
@@ -21,42 +31,64 @@ type PropsType = {
   selectedRegistry: any;
   selectedRegistry: any;
   setDockerfilePath: (x: string) => void;
   setDockerfilePath: (x: string) => void;
   setFolderPath: (x: string) => void;
   setFolderPath: (x: string) => void;
+  setBuildConfig: (x: any) => void;
 };
 };
 
 
-type StateType = {
-  dockerRepo: string;
-  error: boolean;
-  registries: any[] | null;
-  loading: boolean;
+type Buildpack = {
+  name: string;
+  buildpack: string;
+  config: {
+    [key: string]: string;
+  };
 };
 };
 
 
-export default class ActionDetails extends Component<PropsType, StateType> {
-  state = {
-    dockerRepo: "",
-    error: false,
-    registries: null as any[] | null,
-    loading: true,
-  };
+type DetectedBuildpack = {
+  name: string;
+  builders: string[];
+  detected: Buildpack[];
+  others: Buildpack[];
+};
+
+type DetectBuildpackResponse = DetectedBuildpack[];
+
+const ActionDetails: React.FC<PropsType> = (props) => {
+  const {
+    actionConfig,
+    branch,
+    dockerfilePath,
+    folderPath,
+    procfilePath,
+    selectedRegistry,
+    setActionConfig,
+    setDockerfilePath,
+    setFolderPath,
+    setProcfilePath,
+    setProcfileProcess,
+    setSelectedRegistry,
+    setBuildConfig,
+  } = props;
+
+  const { currentProject } = useContext(Context);
+  const [registries, setRegistries] = useState<any[]>(null);
+  const [loading, setLoading] = useState(true);
+  const [showBuildpacksConfig, setShowBuildpacksConfig] = useState(false);
+
+  useEffect(() => {
+    const project_id = currentProject.id;
 
 
-  componentDidMount() {
-    // TODO: Handle custom registry case (unroll repos?)
     api
     api
-      .getProjectRegistries(
-        "<token>",
-        {},
-        { id: this.context.currentProject.id }
-      )
+      .getProjectRegistries("<token>", {}, { id: project_id })
       .then((res: any) => {
       .then((res: any) => {
-        this.setState({ registries: res.data, loading: false });
+        setRegistries(res.data);
+        setLoading(false);
         if (res.data.length === 1) {
         if (res.data.length === 1) {
-          this.props.setSelectedRegistry(res.data[0]);
+          setSelectedRegistry(res.data[0]);
         }
         }
       })
       })
       .catch((err: any) => console.log(err));
       .catch((err: any) => console.log(err));
-  }
+  }, [currentProject]);
 
 
-  renderIntegrationList = () => {
-    let { loading, registries } = this.state;
+  const renderIntegrationList = () => {
     if (loading) {
     if (loading) {
       return (
       return (
         <LoadingWrapper>
         <LoadingWrapper>
@@ -67,20 +99,19 @@ export default class ActionDetails extends Component<PropsType, StateType> {
 
 
     return registries.map((registry: any, i: number) => {
     return registries.map((registry: any, i: number) => {
       let icon =
       let icon =
-        integrationList[registry.service] &&
-        integrationList[registry.service].icon;
+        integrationList[registry?.service] &&
+        integrationList[registry?.service]?.icon;
+
       if (!icon) {
       if (!icon) {
-        icon = integrationList["dockerhub"].icon;
+        icon = integrationList["dockerhub"]?.icon;
       }
       }
+
       return (
       return (
         <RegistryItem
         <RegistryItem
           key={i}
           key={i}
-          isSelected={
-            this.props.selectedRegistry &&
-            registry.id === this.props.selectedRegistry.id
-          }
-          lastItem={i === registries.length - 1}
-          onClick={() => this.props.setSelectedRegistry(registry)}
+          isSelected={selectedRegistry && registry.id === selectedRegistry?.id}
+          lastItem={i === registries?.length - 1}
+          onClick={() => setSelectedRegistry(registry)}
         >
         >
           <img src={icon && icon} />
           <img src={icon && icon} />
           {registry.url}
           {registry.url}
@@ -89,8 +120,7 @@ export default class ActionDetails extends Component<PropsType, StateType> {
     });
     });
   };
   };
 
 
-  renderRegistrySection = () => {
-    let { registries } = this.state;
+  const renderRegistrySection = () => {
     if (!registries || registries.length === 0 || registries.length === 1) {
     if (!registries || registries.length === 0 || registries.length === 1) {
       return;
       return;
     } else {
     } else {
@@ -100,104 +130,395 @@ export default class ActionDetails extends Component<PropsType, StateType> {
             Select an Image Destination
             Select an Image Destination
             <Required>*</Required>
             <Required>*</Required>
           </Subtitle>
           </Subtitle>
-          <ExpandedWrapper>{this.renderIntegrationList()}</ExpandedWrapper>
+          <ExpandedWrapper>{renderIntegrationList()}</ExpandedWrapper>
         </>
         </>
       );
       );
     }
     }
   };
   };
 
 
-  render() {
-    return (
-      <>
-        <DarkMatter />
+  return (
+    <>
+      <DarkMatter />
+      <Heading>GitHub Settings</Heading>
+      <InputRow
+        disabled={true}
+        label="Git repository"
+        type="text"
+        width="100%"
+        value={actionConfig?.git_repo}
+      />
+      <InputRow
+        disabled={true}
+        label="Branch"
+        type="text"
+        width="100%"
+        value={props?.branch}
+      />
+      {dockerfilePath && (
         <InputRow
         <InputRow
           disabled={true}
           disabled={true}
-          label="Git Repository"
+          label="Dockerfile path"
           type="text"
           type="text"
           width="100%"
           width="100%"
-          value={this.props.actionConfig.git_repo}
+          value={dockerfilePath}
         />
         />
-        <InputRow
-          disabled={true}
-          label="Branch"
-          type="text"
-          width="100%"
-          value={this.props.branch}
-        />
-        {this.props.dockerfilePath && (
-          <InputRow
-            disabled={true}
-            label="Dockerfile Path"
-            type="text"
-            width="100%"
-            value={this.props.dockerfilePath}
+      )}
+      <InputRow
+        disabled={true}
+        label={dockerfilePath ? "Docker build context" : "Application folder"}
+        type="text"
+        width="100%"
+        value={folderPath}
+      />
+      {renderRegistrySection()}
+      {!dockerfilePath && (
+        <>
+          <Heading>
+            <ExpandHeader
+              onClick={() => setShowBuildpacksConfig((prev) => !prev)}
+              isExpanded={showBuildpacksConfig}
+            >
+              Buildpacks Settings
+              <i className="material-icons">arrow_drop_down</i>
+            </ExpandHeader>
+          </Heading>
+          <BuildpackSelection
+            actionConfig={actionConfig}
+            branch={branch}
+            folderPath={folderPath}
+            onChange={(config) => {
+              setBuildConfig(config);
+            }}
+            hide={!showBuildpacksConfig}
           />
           />
+          <Buffer />
+        </>
+      )}
+      <Br />
+
+      <Flex>
+        <BackButton
+          width="140px"
+          onClick={() => {
+            setDockerfilePath(null);
+            setFolderPath(null);
+            setProcfilePath(null);
+            setProcfileProcess(null);
+            setSelectedRegistry(null);
+          }}
+        >
+          <i className="material-icons">keyboard_backspace</i>
+          Select Folder
+        </BackButton>
+        {selectedRegistry ? (
+          <StatusWrapper successful={true}>
+            <i className="material-icons">done</i> Source selected
+          </StatusWrapper>
+        ) : (
+          <StatusWrapper>
+            <i className="material-icons">error_outline</i>A connected container
+            registry is required
+          </StatusWrapper>
         )}
         )}
-        <InputRow
-          disabled={true}
-          label={
-            this.props.dockerfilePath
-              ? "Docker Build Context"
-              : "Application Folder"
-          }
-          type="text"
+      </Flex>
+    </>
+  );
+};
+
+export default ActionDetails;
+
+const DEFAULT_BUILDER_NAME = "heroku";
+const DEFAULT_PAKETO_STACK = "paketobuildpacks/builder:full";
+const DEFAULT_HEROKU_STACK = "heroku/buildpacks:20";
+
+type BuildConfig = {
+  builder: string;
+  buildpacks: string[];
+  config: null | {
+    [key: string]: string;
+  };
+};
+
+export const BuildpackSelection: React.FC<{
+  actionConfig: ActionConfigType;
+  folderPath: string;
+  branch: string;
+  hide: boolean;
+  onChange: (config: BuildConfig) => void;
+}> = ({ actionConfig, folderPath, branch, hide, onChange }) => {
+  const { currentProject } = useContext(Context);
+
+  const [builders, setBuilders] = useState<DetectedBuildpack[]>(null);
+  const [selectedBuilder, setSelectedBuilder] = useState<string>(null);
+
+  const [stacks, setStacks] = useState<string[]>(null);
+  const [selectedStack, setSelectedStack] = useState<string>(null);
+
+  const [selectedBuildpacks, setSelectedBuildpacks] = useState<Buildpack[]>([]);
+  const [availableBuildpacks, setAvailableBuildpacks] = useState<Buildpack[]>(
+    []
+  );
+
+  useEffect(() => {
+    let buildConfig: BuildConfig = {} as BuildConfig;
+
+    buildConfig.builder = selectedStack;
+    buildConfig.buildpacks = selectedBuildpacks?.map((buildpack) => {
+      return buildpack.buildpack;
+    });
+    if (typeof onChange === "function") {
+      onChange(buildConfig);
+    }
+  }, [selectedBuilder, selectedStack, selectedBuildpacks]);
+
+  useEffect(() => {
+    api
+      .detectBuildpack<DetectBuildpackResponse>(
+        "<token>",
+        {
+          dir: folderPath || ".",
+        },
+        {
+          project_id: currentProject.id,
+          git_repo_id: actionConfig.git_repo_id,
+          kind: "github",
+          owner: actionConfig.git_repo.split("/")[0],
+          name: actionConfig.git_repo.split("/")[1],
+          branch: branch,
+        }
+      )
+      // getMockData()
+      .then(({ data }) => {
+        const builders = data;
+
+        const defaultBuilder = builders.find(
+          (builder) => builder.name.toLowerCase() === DEFAULT_BUILDER_NAME
+        );
+
+        const detectedBuildpacks = defaultBuilder.detected;
+        const availableBuildpacks = defaultBuilder.others;
+        const defaultStack = defaultBuilder.builders.find((stack) => {
+          return (
+            stack === DEFAULT_HEROKU_STACK || stack === DEFAULT_PAKETO_STACK
+          );
+        });
+
+        setBuilders(builders);
+        setSelectedBuilder(defaultBuilder.name.toLowerCase());
+
+        setStacks(defaultBuilder.builders);
+        setSelectedStack(defaultStack);
+        if (!Array.isArray(detectedBuildpacks)) {
+          setSelectedBuildpacks([]);
+        } else {
+          setSelectedBuildpacks(detectedBuildpacks);
+        }
+        if (!Array.isArray(availableBuildpacks)) {
+          setAvailableBuildpacks([]);
+        } else {
+          setAvailableBuildpacks(availableBuildpacks);
+        }
+      })
+      .catch((err) => {
+        console.error(err);
+      });
+  }, [currentProject, actionConfig]);
+
+  const builderOptions = useMemo(() => {
+    if (!Array.isArray(builders)) {
+      return;
+    }
+
+    return builders.map((builder) => ({
+      label: builder.name,
+      value: builder.name.toLowerCase(),
+    }));
+  }, [builders]);
+
+  const stackOptions = useMemo(() => {
+    if (!Array.isArray(stacks)) {
+      return;
+    }
+
+    return stacks.map((stack) => ({
+      label: stack,
+      value: stack.toLowerCase(),
+    }));
+  }, [stacks]);
+
+  const handleSelectBuilder = (builderName: string) => {
+    const builder = builders.find(
+      (b) => b.name.toLowerCase() === builderName.toLowerCase()
+    );
+    const detectedBuildpacks = builder.detected;
+    const availableBuildpacks = builder.others;
+    const defaultStack = builder.builders.find((stack) => {
+      return stack === DEFAULT_HEROKU_STACK || stack === DEFAULT_PAKETO_STACK;
+    });
+    setSelectedBuilder(builderName);
+    setBuilders(builders);
+    setSelectedBuilder(builderName.toLowerCase());
+
+    setStacks(builder.builders);
+    setSelectedStack(defaultStack);
+
+    if (!Array.isArray(detectedBuildpacks)) {
+      setSelectedBuildpacks([]);
+    } else {
+      setSelectedBuildpacks(detectedBuildpacks);
+    }
+    if (!Array.isArray(availableBuildpacks)) {
+      setAvailableBuildpacks([]);
+    } else {
+      setAvailableBuildpacks(availableBuildpacks);
+    }
+  };
+
+  const renderBuildpacksList = (
+    buildpacks: Buildpack[],
+    action: "remove" | "add"
+  ) => {
+    return buildpacks?.map((buildpack) => {
+      const icon = `devicon-${buildpack?.name?.toLowerCase()}-plain colored`;
+
+      return (
+        <StyledCard>
+          <ContentContainer>
+            <Icon className={icon} />
+            <EventInformation>
+              <EventName>{buildpack?.name}</EventName>
+            </EventInformation>
+          </ContentContainer>
+          <ActionContainer>
+            {action === "add" && (
+              <DeleteButton
+                onClick={() => handleAddBuildpack(buildpack.buildpack)}
+              >
+                <span className="material-icons-outlined">add</span>
+              </DeleteButton>
+            )}
+            {action === "remove" && (
+              <DeleteButton
+                onClick={() => handleRemoveBuildpack(buildpack.buildpack)}
+              >
+                <span className="material-icons">delete</span>
+              </DeleteButton>
+            )}
+          </ActionContainer>
+        </StyledCard>
+      );
+    });
+  };
+
+  const handleRemoveBuildpack = (buildpackToRemove: string) => {
+    setSelectedBuildpacks((selBuildpacks) => {
+      const tmpSelectedBuildpacks = [...selBuildpacks];
+
+      const indexBuildpackToRemove = tmpSelectedBuildpacks.findIndex(
+        (buildpack) => buildpack.buildpack === buildpackToRemove
+      );
+      const buildpack = tmpSelectedBuildpacks[indexBuildpackToRemove];
+
+      setAvailableBuildpacks((availableBuildpacks) => [
+        ...availableBuildpacks,
+        buildpack,
+      ]);
+
+      tmpSelectedBuildpacks.splice(indexBuildpackToRemove, 1);
+
+      return [...tmpSelectedBuildpacks];
+    });
+  };
+
+  const handleAddBuildpack = (buildpackToAdd: string) => {
+    setAvailableBuildpacks((avBuildpacks) => {
+      const tmpAvailableBuildpacks = [...avBuildpacks];
+      const indexBuildpackToAdd = tmpAvailableBuildpacks.findIndex(
+        (buildpack) => buildpack.buildpack === buildpackToAdd
+      );
+      const buildpack = tmpAvailableBuildpacks[indexBuildpackToAdd];
+
+      setSelectedBuildpacks((selectedBuildpacks) => [
+        ...selectedBuildpacks,
+        buildpack,
+      ]);
+
+      tmpAvailableBuildpacks.splice(indexBuildpackToAdd, 1);
+      return [...tmpAvailableBuildpacks];
+    });
+  };
+
+  if (hide) {
+    return null;
+  }
+
+  if (!stackOptions?.length || !builderOptions?.length) {
+    return <Loading />;
+  }
+
+  return (
+    <BuildpackConfigurationContainer>
+      <>
+        <SelectRow
+          value={selectedBuilder}
           width="100%"
           width="100%"
-          value={this.props.folderPath}
+          options={builderOptions}
+          setActiveValue={(option) => handleSelectBuilder(option)}
+          label="Select a builder"
         />
         />
-        {this.renderRegistrySection()}
-        <Br />
-
-        <Flex>
-          <BackButton
-            width="140px"
-            onClick={() => {
-              this.props.setDockerfilePath(null);
-              this.props.setFolderPath(null);
-              this.props.setProcfilePath(null);
-              this.props.setProcfileProcess(null);
-              this.props.setSelectedRegistry(null);
-            }}
-          >
-            <i className="material-icons">keyboard_backspace</i>
-            Select Folder
-          </BackButton>
-          {
-            // !this.props.procfilePath && !this.props.dockerfilePath ? (
-            //   <StatusWrapper>
-            //     <i className="material-icons">error_outline</i>
-            //     Procfile not detected.
-            //   </StatusWrapper>
-            // ) :
-            this.props.selectedRegistry ? (
-              <StatusWrapper successful={true}>
-                <i className="material-icons">done</i> Source selected
-              </StatusWrapper>
-            ) : (
-              <StatusWrapper>
-                <i className="material-icons">error_outline</i>A connected
-                container registry is required
-              </StatusWrapper>
-            )
-          }
-        </Flex>
+
+        <SelectRow
+          value={selectedStack}
+          width="100%"
+          options={stackOptions}
+          setActiveValue={(option) => setSelectedStack(option)}
+          label="Select your stack"
+        />
+        <Helper>
+          The following buildpacks were automatically detected. You can also
+          manually add/remove buildpacks.
+        </Helper>
+
+        {!!selectedBuildpacks?.length &&
+          renderBuildpacksList(selectedBuildpacks, "remove")}
+
+        {!!availableBuildpacks?.length && (
+          <>
+            <Helper>Available buildpacks:</Helper>
+            {renderBuildpacksList(availableBuildpacks, "add")}
+          </>
+        )}
       </>
       </>
-    );
-  }
-}
+    </BuildpackConfigurationContainer>
+  );
+};
 
 
-ActionDetails.contextType = Context;
+const fadeIn = keyframes`
+  from {
+    opacity: 0;
+  }
+  to {
+    opacity: 1;
+  }
+`;
 
 
-const Highlight = styled.a`
-  color: #949eff;
-  text-decoration: none;
-  margin-left: 5px;
+const ExpandHeader = styled.div<{ isExpanded: boolean }>`
+  display: flex;
+  align-items: center;
   cursor: pointer;
   cursor: pointer;
+  > i {
+    margin-left: 10px;
+    transform: ${(props) => (props.isExpanded ? "" : "rotate(180deg)")};
+  }
 `;
 `;
 
 
-const Bold = styled.div`
-  font-weight: 800;
-  color: #ffffff;
-  margin-right: 5px;
+const BuildpackConfigurationContainer = styled.div`
+  animation: ${fadeIn} 0.75s;
+`;
+
+const Buffer = styled.div`
+  width: 100%;
+  height: 8px;
 `;
 `;
 
 
 const Required = styled.div`
 const Required = styled.div`
@@ -210,19 +531,6 @@ const Subtitle = styled.div`
   margin-top: 21px;
   margin-top: 21px;
 `;
 `;
 
 
-const SubtitleAlt = styled.div`
-  padding: 11px 0px 16px;
-  font-family: "Work Sans", sans-serif;
-  font-size: 13px;
-  color: #aaaabb;
-  line-height: 1.6em;
-  display: flex;
-  align-items: center;
-  margin-top: -3px;
-  margin-bottom: -7px;
-  font-weight: 400;
-`;
-
 const RegistryItem = styled.div`
 const RegistryItem = styled.div`
   display: flex;
   display: flex;
   width: 100%;
   width: 100%;
@@ -338,10 +646,6 @@ const BackButton = styled.div`
   }
   }
 `;
 `;
 
 
-const AdvancedHeader = styled.div`
-  margin-top: 15px;
-`;
-
 const Br = styled.div`
 const Br = styled.div`
   width: 100%;
   width: 100%;
   height: 1px;
   height: 1px;
@@ -350,9 +654,82 @@ const Br = styled.div`
 
 
 const DarkMatter = styled.div`
 const DarkMatter = styled.div`
   width: 100%;
   width: 100%;
-  margin-bottom: -18px;
+  margin-bottom: -28px;
 `;
 `;
 
 
-const Holder = styled.div`
-  padding: 0px 12px 24px 12px;
+const StyledCard = styled.div`
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  border: 1px solid #ffffff00;
+  background: #ffffff08;
+  margin-bottom: 5px;
+  border-radius: 8px;
+  padding: 14px;
+  overflow: hidden;
+  height: 60px;
+  font-size: 13px;
+  animation: ${fadeIn} 0.5s;
+`;
+
+const ContentContainer = styled.div`
+  display: flex;
+  height: 100%;
+  width: 100%;
+  align-items: center;
+`;
+
+const Icon = styled.span`
+  font-size: 20px;
+  margin-left: 10px;
+  margin-right: 20px;
+`;
+
+const EventInformation = styled.div`
+  display: flex;
+  flex-direction: column;
+  justify-content: space-around;
+  height: 100%;
+`;
+
+const EventName = styled.div`
+  font-family: "Work Sans", sans-serif;
+  font-weight: 500;
+  color: #ffffff;
+`;
+
+const EventReason = styled.div`
+  font-family: "Work Sans", sans-serif;
+  color: #aaaabb;
+  margin-top: 5px;
+`;
+
+const ActionContainer = styled.div`
+  display: flex;
+  align-items: center;
+  white-space: nowrap;
+  height: 100%;
+`;
+
+const DeleteButton = styled.button`
+  position: relative;
+  border: none;
+  background: none;
+  color: white;
+  padding: 5px;
+  display: flex;
+  justify-content: center;
+  align-items: center;
+  border-radius: 50%;
+  cursor: pointer;
+  color: #aaaabb;
+
+  :hover {
+    background: #ffffff11;
+    border: 1px solid #ffffff44;
+  }
+
+  > span {
+    font-size: 20px;
+  }
 `;
 `;

+ 6 - 0
dashboard/src/components/repo-selector/ContentsList.tsx

@@ -317,6 +317,12 @@ export default class ContentsList extends Component<PropsType, StateType> {
                 <Row
                 <Row
                   key={i}
                   key={i}
                   onClick={() => {
                   onClick={() => {
+                    if (
+                      !this.props.folderPath ||
+                      this.props.folderPath === ""
+                    ) {
+                      this.props.setFolderPath("./");
+                    }
                     this.props.setProcfileProcess(process);
                     this.props.setProcfileProcess(process);
                   }}
                   }}
                   isLast={processes.length - 1 === i}
                   isLast={processes.length - 1 === i}

+ 40 - 3
dashboard/src/index.html

@@ -42,13 +42,45 @@
     <script>
     <script>
       window.intercomSettings = {
       window.intercomSettings = {
         app_id: "gq56g49i",
         app_id: "gq56g49i",
-        custom_launcher_selector: '#intercom_help'
+        custom_launcher_selector: "#intercom_help",
       };
       };
     </script>
     </script>
 
 
     <script>
     <script>
-    // We pre-filled your app ID in the widget URL: 'https://widget.intercom.io/widget/gq56g49i'
-    (function(){var w=window;var ic=w.Intercom;if(typeof ic==="function"){ic('reattach_activator');ic('update',w.intercomSettings);}else{var d=document;var i=function(){i.c(arguments);};i.q=[];i.c=function(args){i.q.push(args);};w.Intercom=i;var l=function(){var s=d.createElement('script');s.type='text/javascript';s.async=true;s.src='https://widget.intercom.io/widget/gq56g49i';var x=d.getElementsByTagName('script')[0];x.parentNode.insertBefore(s,x);};if(document.readyState==='complete'){l();}else if(w.attachEvent){w.attachEvent('onload',l);}else{w.addEventListener('load',l,false);}}})();
+      // We pre-filled your app ID in the widget URL: 'https://widget.intercom.io/widget/gq56g49i'
+      (function () {
+        var w = window;
+        var ic = w.Intercom;
+        if (typeof ic === "function") {
+          ic("reattach_activator");
+          ic("update", w.intercomSettings);
+        } else {
+          var d = document;
+          var i = function () {
+            i.c(arguments);
+          };
+          i.q = [];
+          i.c = function (args) {
+            i.q.push(args);
+          };
+          w.Intercom = i;
+          var l = function () {
+            var s = d.createElement("script");
+            s.type = "text/javascript";
+            s.async = true;
+            s.src = "https://widget.intercom.io/widget/gq56g49i";
+            var x = d.getElementsByTagName("script")[0];
+            x.parentNode.insertBefore(s, x);
+          };
+          if (document.readyState === "complete") {
+            l();
+          } else if (w.attachEvent) {
+            w.attachEvent("onload", l);
+          } else {
+            w.addEventListener("load", l, false);
+          }
+        }
+      })();
     </script>
     </script>
 
 
     <script>
     <script>
@@ -141,6 +173,11 @@
       href="https://fonts.googleapis.com/icon?family=Material+Icons|Material+Icons+Outlined|Material+Icons+Round"
       href="https://fonts.googleapis.com/icon?family=Material+Icons|Material+Icons+Outlined|Material+Icons+Round"
       rel="stylesheet"
       rel="stylesheet"
     />
     />
+    <!-- Coding languages icons -->
+    <link
+      rel="stylesheet"
+      href="https://cdn.jsdelivr.net/gh/devicons/devicon@v2.14.0/devicon.min.css"
+    />
   </head>
   </head>
   <body>
   <body>
     <div id="output"></div>
     <div id="output"></div>

+ 2 - 1
dashboard/src/main/home/Home.tsx

@@ -27,6 +27,7 @@ import discordLogo from "../../assets/discord.svg";
 import Onboarding from "./onboarding/Onboarding";
 import Onboarding from "./onboarding/Onboarding";
 import ModalHandler from "./ModalHandler";
 import ModalHandler from "./ModalHandler";
 import { NewProjectFC } from "./new-project/NewProject";
 import { NewProjectFC } from "./new-project/NewProject";
+import { BuildpackSelection } from "components/repo-selector/ActionDetails";
 
 
 // Guarded components
 // Guarded components
 const GuardedProjectSettings = fakeGuardedRoute("settings", "", [
 const GuardedProjectSettings = fakeGuardedRoute("settings", "", [
@@ -391,7 +392,7 @@ class Home extends Component<PropsType, StateType> {
           </>
           </>
         )}
         )}
 
 
-        <ViewWrapper>
+        <ViewWrapper id="HomeViewWrapper">
           <Navbar
           <Navbar
             logOut={this.props.logOut}
             logOut={this.props.logOut}
             currentView={this.props.currentRoute} // For form feedback
             currentView={this.props.currentRoute} // For form feedback

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

@@ -11,14 +11,16 @@ import { NamespaceList } from "./NamespaceList";
 import ClusterSettings from "./ClusterSettings";
 import ClusterSettings from "./ClusterSettings";
 import useAuth from "shared/auth/useAuth";
 import useAuth from "shared/auth/useAuth";
 import Metrics from "./Metrics";
 import Metrics from "./Metrics";
+import EventsTab from "./events/EventsTab";
 
 
-type TabEnum = "nodes" | "settings" | "namespaces" | "metrics";
+type TabEnum = "nodes" | "settings" | "namespaces" | "metrics" | "events";
 
 
 const tabOptions: {
 const tabOptions: {
   label: string;
   label: string;
   value: TabEnum;
   value: TabEnum;
 }[] = [
 }[] = [
   { label: "Nodes", value: "nodes" },
   { label: "Nodes", value: "nodes" },
+  { label: "Events", value: "events" },
   { label: "Metrics", value: "metrics" },
   { label: "Metrics", value: "metrics" },
   { label: "Namespaces", value: "namespaces" },
   { label: "Namespaces", value: "namespaces" },
   { label: "Settings", value: "settings" },
   { label: "Settings", value: "settings" },
@@ -32,6 +34,8 @@ export const Dashboard: React.FunctionComponent = () => {
   const context = useContext(Context);
   const context = useContext(Context);
   const renderTab = () => {
   const renderTab = () => {
     switch (currentTab) {
     switch (currentTab) {
+      case "events":
+        return <EventsTab />;
       case "settings":
       case "settings":
         return <ClusterSettings />;
         return <ClusterSettings />;
       case "metrics":
       case "metrics":

+ 215 - 0
dashboard/src/main/home/cluster-dashboard/dashboard/events/EventsTab.tsx

@@ -0,0 +1,215 @@
+import React, { useContext, useEffect, useState } from "react";
+import styled from "styled-components";
+import EventCard from "components/events/EventCard";
+import Loading from "components/Loading";
+import InfiniteScroll from "react-infinite-scroll-component";
+import Dropdown from "components/Dropdown";
+import { useKubeEvents } from "components/events/useEvents";
+import SubEventsList from "components/events/SubEventsList";
+
+const availableResourceTypes = [
+  { label: "Pods", value: "POD" },
+  { label: "HPA", value: "HPA" },
+  { label: "Nodes", value: "NODE" },
+];
+
+const EventsTab = () => {
+  const [resourceType, setResourceType] = useState(availableResourceTypes[0]);
+  const [currentEvent, setCurrentEvent] = useState(null);
+
+  const {
+    isLoading,
+    hasPorterAgent,
+    triggerInstall,
+    kubeEvents,
+    loadMoreEvents,
+    hasMore,
+  } = useKubeEvents({ resourceType: resourceType.value as any });
+
+  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
+          on your cluster.
+          <InstallPorterAgentButton onClick={() => triggerInstall()}>
+            <i className="material-icons">add</i> Install Porter agent
+          </InstallPorterAgentButton>
+        </div>
+      </Placeholder>
+    );
+  }
+
+  if (currentEvent) {
+    return (
+      <SubEventsList
+        event={currentEvent}
+        clearSelectedEvent={() => setCurrentEvent(null)}
+        enableTopMargin
+      />
+    );
+  }
+
+  return (
+    <EventsPageWrapper>
+      {kubeEvents.length > 0 ? (
+        <>
+          <ControlRow>
+            {/*
+              <Dropdown
+                selectedOption={resourceType}
+                options={availableResourceTypes}
+                onSelect={(o) => setResourceType({ ...o, value: o.value as string })}
+              />
+              */}
+          </ControlRow>
+          <InfiniteScroll
+            dataLength={kubeEvents.length}
+            next={loadMoreEvents}
+            hasMore={hasMore}
+            loader={<h4>Loading...</h4>}
+            scrollableTarget="HomeViewWrapper"
+          >
+            <EventsGrid>
+              {kubeEvents.map((event, i) => {
+                return (
+                  <React.Fragment key={i}>
+                    <EventCard
+                      event={event}
+                      selectEvent={() => setCurrentEvent(event)}
+                    />
+                  </React.Fragment>
+                );
+              })}
+            </EventsGrid>
+          </InfiniteScroll>
+        </>
+      ) : (
+        <Placeholder>
+          <i className="material-icons">search</i>
+          No matching events were found.
+        </Placeholder>
+      )}
+    </EventsPageWrapper>
+  );
+};
+
+export default EventsTab;
+
+const Label = styled.div`
+  color: #ffffff44;
+  margin-right: 8px;
+  font-size: 13px;
+`;
+
+const ControlRow = styled.div`
+  display: flex;
+  align-items: center;
+  margin-bottom: 30px;
+  padding-left: 0px;
+  font-size: 13px;
+`;
+
+const EventsPageWrapper = styled.div`
+  font-size: 13px;
+  padding-bottom: 80px;
+  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 EventsGrid = styled.div`
+  display: grid;
+  grid-row-gap: 15px;
+  grid-template-columns: 1;
+`;
+
+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;
+  box-shadow: 0 5px 8px 0px #00000010;
+  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;
+  margin-top: 35px;
+  padding-bottom: 40px;
+  font-size: 13px;
+  color: #ffffff44;
+  min-height: 400px;
+  height: 50vh;
+  background: #ffffff11;
+  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;
+`;

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

@@ -78,10 +78,6 @@ class EnvGroupDashboard extends Component<PropsType, StateType> {
               </Button>
               </Button>
             )}
             )}
             <SortFilterWrapper>
             <SortFilterWrapper>
-              <SortSelector
-                setSortType={(sortType) => this.setState({ sortType })}
-                sortType={this.state.sortType}
-              />
               <NamespaceSelector
               <NamespaceSelector
                 setNamespace={(namespace) =>
                 setNamespace={(namespace) =>
                   this.setState({ namespace }, () => {
                   this.setState({ namespace }, () => {
@@ -92,6 +88,10 @@ class EnvGroupDashboard extends Component<PropsType, StateType> {
                 }
                 }
                 namespace={this.state.namespace}
                 namespace={this.state.namespace}
               />
               />
+              <SortSelector
+                setSortType={(sortType) => this.setState({ sortType })}
+                sortType={this.state.sortType}
+              />
             </SortFilterWrapper>
             </SortFilterWrapper>
           </ControlRow>
           </ControlRow>
 
 
@@ -145,9 +145,11 @@ EnvGroupDashboard.contextType = Context;
 export default withRouter(withAuth(EnvGroupDashboard));
 export default withRouter(withAuth(EnvGroupDashboard));
 
 
 const SortFilterWrapper = styled.div`
 const SortFilterWrapper = styled.div`
-  width: 468px;
   display: flex;
   display: flex;
   justify-content: space-between;
   justify-content: space-between;
+  > div:not(:first-child) {
+    margin-left: 30px;
+  }
 `;
 `;
 
 
 const ControlRow = styled.div`
 const ControlRow = styled.div`

+ 1 - 1
dashboard/src/main/home/cluster-dashboard/env-groups/EnvGroupList.tsx

@@ -82,7 +82,7 @@ export default class EnvGroupList extends Component<PropsType, StateType> {
   }
   }
 
 
   componentDidUpdate(prevProps: PropsType) {
   componentDidUpdate(prevProps: PropsType) {
-    // Ret2: Prevents reload when opening ClusterConfigModal
+    // Prevents reload when opening ClusterConfigModal
     if (
     if (
       prevProps.currentCluster !== this.props.currentCluster ||
       prevProps.currentCluster !== this.props.currentCluster ||
       prevProps.namespace !== this.props.namespace ||
       prevProps.namespace !== this.props.namespace ||

+ 114 - 111
dashboard/src/main/home/cluster-dashboard/expanded-chart/ExpandedChart.tsx

@@ -347,12 +347,12 @@ const ExpandedChart: React.FC<Props> = (props) => {
   const renderTabContents = (currentTab: string) => {
   const renderTabContents = (currentTab: string) => {
     let { setSidebar } = props;
     let { setSidebar } = props;
     let chart = currentChart;
     let chart = currentChart;
-
+    console.log("CONTROLLERS", controllers);
     switch (currentTab) {
     switch (currentTab) {
       case "metrics":
       case "metrics":
         return <MetricsSection currentChart={chart} />;
         return <MetricsSection currentChart={chart} />;
       case "events":
       case "events":
-        return <EventsTab currentChart={chart} />;
+        return <EventsTab controllers={controllers} />;
       case "status":
       case "status":
         if (isLoadingChartData) {
         if (isLoadingChartData) {
           return (
           return (
@@ -384,7 +384,12 @@ const ExpandedChart: React.FC<Props> = (props) => {
             </Placeholder>
             </Placeholder>
           );
           );
         } else {
         } else {
-          return <StatusSection currentChart={chart} setFullScreenLogs={() => setFullScreenLogs(true)} />;
+          return (
+            <StatusSection
+              currentChart={chart}
+              setFullScreenLogs={() => setFullScreenLogs(true)}
+            />
+          );
         }
         }
       case "settings":
       case "settings":
         return (
         return (
@@ -663,114 +668,112 @@ const ExpandedChart: React.FC<Props> = (props) => {
 
 
   return (
   return (
     <>
     <>
-      { 
-        fullScreenLogs ? (
-          <StatusSection
-            fullscreen={true} 
-            currentChart={currentChart} 
-            setFullScreenLogs={() => setFullScreenLogs(false)}
-          />
-        ) : (
-      <StyledExpandedChart>
-        <HeaderWrapper>
-          <BackButton onClick={props.closeChart}>
-            <BackButtonImg src={backArrow} />
-          </BackButton>
-          <TitleSection
-            icon={currentChart.chart.metadata.icon}
-            iconWidth="33px"
-          >
-            {currentChart.name}
-            <DeploymentType currentChart={currentChart} />
-            <TagWrapper>
-              Namespace <NamespaceTag>{currentChart.namespace}</NamespaceTag>
-            </TagWrapper>
-          </TitleSection>
-
-          {currentChart.chart.metadata.name != "worker" &&
-            currentChart.chart.metadata.name != "job" &&
-            renderUrl()}
-          <InfoWrapper>
-            <StatusIndicator
-              controllers={controllers}
-              status={currentChart.info.status}
-              margin_left={"0px"}
-            />
-            <LastDeployed>
-              <Dot>•</Dot>Last deployed
-              {" " + getReadableDate(currentChart.info.last_deployed)}
-            </LastDeployed>
-          </InfoWrapper>
-        </HeaderWrapper>
-        {deleting ? (
-          <>
-            <LineBreak />
-            <Placeholder>
-              <TextWrap>
-                <Header>
-                  <Spinner src={loadingSrc} /> Deleting "{currentChart.name}"
-                </Header>
-                You will be automatically redirected after deletion is complete.
-              </TextWrap>
-            </Placeholder>
-          </>
-        ) : (
-          <>
-            <RevisionSection
-              showRevisions={showRevisions}
-              toggleShowRevisions={() => {
-                setShowRevisions(!showRevisions);
-              }}
-              chart={currentChart}
-              refreshChart={() => getChartData(currentChart)}
-              setRevision={setRevision}
-              forceRefreshRevisions={forceRefreshRevisions}
-              refreshRevisionsOff={() => setForceRefreshRevisions(false)}
-              status={chartStatus}
-              shouldUpdate={
-                currentChart.latest_version &&
-                currentChart.latest_version !==
-                  currentChart.chart.metadata.version
-              }
-              latestVersion={currentChart.latest_version}
-              upgradeVersion={handleUpgradeVersion}
-            />
-            {(isPreview || leftTabOptions.length > 0) && (
-              <BodyWrapper>
-                <PorterFormWrapper
-                  formData={currentChart.form}
-                  valuesToOverride={{
-                    namespace: props.namespace,
-                    clusterId: currentCluster.id,
-                    currentChart: {
-                      name: currentChart.name,
-                    },
-                  }}
-                  renderTabContents={renderTabContents}
-                  isReadOnly={
-                    imageIsPlaceholder ||
-                    !isAuthorized("application", "", ["get", "update"])
-                  }
-                  onSubmit={onSubmit}
-                  rightTabOptions={rightTabOptions}
-                  leftTabOptions={leftTabOptions}
-                  color={isPreview ? "#f5cb42" : null}
-                  addendum={
-                    <TabButton
-                      onClick={toggleDevOpsMode}
-                      devOpsMode={devOpsMode}
-                    >
-                      <i className="material-icons">offline_bolt</i> DevOps Mode
-                    </TabButton>
-                  }
-                  saveValuesStatus={saveValuesStatus}
-                />
-              </BodyWrapper>
-            )}
-          </>
-        )}
-      </StyledExpandedChart>
-    )}
+      {fullScreenLogs ? (
+        <StatusSection
+          fullscreen={true}
+          currentChart={currentChart}
+          setFullScreenLogs={() => setFullScreenLogs(false)}
+        />
+      ) : (
+        <StyledExpandedChart>
+          <HeaderWrapper>
+            <BackButton onClick={props.closeChart}>
+              <BackButtonImg src={backArrow} />
+            </BackButton>
+            <TitleSection
+              icon={currentChart.chart.metadata.icon}
+              iconWidth="33px"
+            >
+              {currentChart.name}
+              <DeploymentType currentChart={currentChart} />
+              <TagWrapper>
+                Namespace <NamespaceTag>{currentChart.namespace}</NamespaceTag>
+              </TagWrapper>
+            </TitleSection>
+
+            {currentChart.chart.metadata.name != "worker" &&
+              currentChart.chart.metadata.name != "job" &&
+              renderUrl()}
+            <InfoWrapper>
+              <StatusIndicator
+                controllers={controllers}
+                status={currentChart.info.status}
+                margin_left={"0px"}
+              />
+              <LastDeployed>
+                <Dot>•</Dot>Last deployed
+                {" " + getReadableDate(currentChart.info.last_deployed)}
+              </LastDeployed>
+            </InfoWrapper>
+          </HeaderWrapper>
+          {deleting ? (
+            <>
+              <LineBreak />
+              <Placeholder>
+                <TextWrap>
+                  <Header>
+                    <Spinner src={loadingSrc} /> Deleting "{currentChart.name}"
+                  </Header>
+                  You will be automatically redirected after deletion is
+                  complete.
+                </TextWrap>
+              </Placeholder>
+            </>
+          ) : (
+            <>
+              <RevisionSection
+                showRevisions={showRevisions}
+                toggleShowRevisions={() => {
+                  setShowRevisions(!showRevisions);
+                }}
+                chart={currentChart}
+                refreshChart={() => getChartData(currentChart)}
+                setRevision={setRevision}
+                forceRefreshRevisions={forceRefreshRevisions}
+                refreshRevisionsOff={() => setForceRefreshRevisions(false)}
+                status={chartStatus}
+                shouldUpdate={
+                  currentChart.latest_version &&
+                  currentChart.latest_version !==
+                    currentChart.chart.metadata.version
+                }
+                latestVersion={currentChart.latest_version}
+                upgradeVersion={handleUpgradeVersion}
+              />
+              {(isPreview || leftTabOptions.length > 0) && (
+                <BodyWrapper>
+                  <PorterFormWrapper
+                    formData={currentChart.form}
+                    valuesToOverride={{
+                      namespace: props.namespace,
+                      clusterId: currentCluster.id,
+                    }}
+                    renderTabContents={renderTabContents}
+                    isReadOnly={
+                      imageIsPlaceholder ||
+                      !isAuthorized("application", "", ["get", "update"])
+                    }
+                    onSubmit={onSubmit}
+                    rightTabOptions={rightTabOptions}
+                    leftTabOptions={leftTabOptions}
+                    color={isPreview ? "#f5cb42" : null}
+                    addendum={
+                      <TabButton
+                        onClick={toggleDevOpsMode}
+                        devOpsMode={devOpsMode}
+                      >
+                        <i className="material-icons">offline_bolt</i> DevOps
+                        Mode
+                      </TabButton>
+                    }
+                    saveValuesStatus={saveValuesStatus}
+                  />
+                </BodyWrapper>
+              )}
+            </>
+          )}
+        </StyledExpandedChart>
+      )}
     </>
     </>
   );
   );
 };
 };

+ 0 - 120
dashboard/src/main/home/cluster-dashboard/expanded-chart/events/EventCard.tsx

@@ -1,120 +0,0 @@
-import React, { useState } from "react";
-import styled from "styled-components";
-import { Event } from "./EventsTab";
-import Loading from "../../../../../components/Loading";
-
-type CardProps = {
-  event: Event;
-  selectEvent?: () => void;
-  overrideName?: string;
-};
-
-export const getReadableDate = (s: number) => {
-  let ts = new Date(s * 1000);
-  let date = ts.toLocaleDateString();
-  let time = ts.toLocaleTimeString([], {
-    hour: "numeric",
-    minute: "2-digit",
-  });
-  return `${time} ${date}`;
-};
-
-// Rename to Event Card
-const EventCard: React.FunctionComponent<CardProps> = ({
-  event,
-  selectEvent,
-  overrideName,
-}) => {
-  return (
-    <StyledCard onClick={() => selectEvent && selectEvent()}>
-      {event.status == 1 && (
-        <Icon status="normal" className="material-icons-outlined">
-          check
-        </Icon>
-      )}
-      {event.status == 2 && (
-        <Icon className="material-icons-outlined">autorenew</Icon>
-      )}
-      {event.status == 3 && (
-        <Icon status="critical" className="material-icons-outlined">
-          error
-        </Icon>
-      )}
-
-      <InfoWrapper>
-        <EventName>
-          {overrideName ? overrideName : event.name}
-          {event.status == 1 && " successful"}
-          {event.status == 2 && " in progress"}
-          {event.status == 3 && ` failed: ${event.info}`}
-        </EventName>
-        <TimestampContainer>
-          <i className="material-icons-outlined">access_time</i>
-          {getReadableDate(event.time)}
-        </TimestampContainer>
-      </InfoWrapper>
-    </StyledCard>
-  );
-};
-
-export default EventCard;
-
-const StyledCard = styled.div`
-  display: flex;
-  align-items: center;
-  border: 1px solid #ffffff44;
-  background: #ffffff08;
-  margin-bottom: 10px;
-  border-radius: 10px;
-  padding-left: 20px;
-  overflow: hidden;
-  height: 80px;
-  cursor: pointer;
-
-  :hover {
-    background: #ffffff11;
-    border: 1px solid #ffffff66;
-  }
-`;
-
-const Icon = styled.span<{ status?: "critical" | "normal" }>`
-  font-size: 22px;
-  margin-right: 18px;
-  color: ${({ status }) =>
-    status ? (status === "critical" ? "#cc3d42" : "#38a88a") : "#efefef"};
-  animation: ${({ status }) => !status && "rotating 3s linear infinite"};
-  @keyframes rotating {
-    from {
-      transform: rotate(0deg);
-    }
-    to {
-      transform: rotate(360deg);
-    }
-  }
-`;
-
-const InfoWrapper = styled.div`
-  display: flex;
-  flex-direction: column;
-`;
-
-const EventName = styled.div`
-  font-size: 13px;
-  font-family: "Work Sans", sans-serif;
-  font-weight: 500;
-  color: #ffffff;
-`;
-
-const TimestampContainer = styled.div`
-  display: flex;
-  align-items: center;
-  color: #ffffff55;
-  font-size: 13px;
-  margin-top: 8px;
-
-  > i {
-    margin-right: 5px;
-    font-size: 18px;
-    margin-left: -1px;
-  }
-`;

+ 0 - 94
dashboard/src/main/home/cluster-dashboard/expanded-chart/events/EventDetail.tsx

@@ -1,94 +0,0 @@
-import React, { Fragment } from "react";
-import { EventContainer } from "./EventsTab";
-import TitleSection from "components/TitleSection";
-import EventCard, { getReadableDate } from "./EventCard";
-import styled from "styled-components";
-
-interface Props {
-  container: EventContainer;
-  resetSelection: () => {};
-}
-
-const EventDetail: React.FC<Props> = (props) => {
-  return (
-    <>
-      <Flex>
-        <TitleSection handleNavBack={props.resetSelection}>
-          {props.container.name}
-        </TitleSection>
-        <P>
-          <i className="material-icons-outlined">access_time</i>
-          {getReadableDate(props.container.started_at)}
-        </P>
-      </Flex>
-      <EventsGrid>
-        {props.container.events
-          .slice(0)
-          .reverse()
-          .map((event) => {
-            return (
-              <React.Fragment key={event.index}>
-                <EventCard event={event} />
-              </React.Fragment>
-            );
-          })}
-      </EventsGrid>
-    </>
-  );
-};
-
-export default EventDetail;
-
-const Flex = styled.div`
-  display: flex;
-  align-items: center;
-  margin-bottom: 10px;
-`;
-
-const P = styled.p`
-  display: flex;
-  align-items: center;
-  color: #ffffff44;
-  font-size: 13px;
-  margin-left: 20px;
-  margin-top: 0px;
-
-  > i {
-    margin-right: 5px;
-    font-size: 18px;
-    margin-left: -1px;
-  }
-`;
-
-const BackButton = styled.div`
-  display: flex;
-  align-items: center;
-  justify-content: space-between;
-  cursor: pointer;
-  font-size: 13px;
-  height: 35px;
-  padding: 5px 16px;
-  padding-right: 15px;
-  border: 1px solid #ffffff55;
-  border-radius: 100px;
-  width: ${(props: { width: string }) => props.width};
-  color: white;
-  background: #ffffff11;
-
-  :hover {
-    background: #ffffff22;
-  }
-
-  > i {
-    color: white;
-    font-size: 16px;
-    margin-right: 6px;
-    margin-left: -2px;
-  }
-`;
-
-const EventsGrid = styled.div`
-  display: grid;
-  grid-row-gap: 15px;
-  grid-template-columns: 1;
-`;

+ 189 - 159
dashboard/src/main/home/cluster-dashboard/expanded-chart/events/EventsTab.tsx

@@ -1,185 +1,227 @@
-import React, { useContext, useEffect, useState } from "react";
+import React, { useEffect, useMemo, useState } from "react";
 import styled from "styled-components";
 import styled from "styled-components";
-
-import loadingSrc from "assets/loading.gif";
-import { Context } from "shared/Context";
-import { ChartType } from "../../../../../shared/types";
-import api from "../../../../../shared/api";
-import EventCard from "./EventCard";
+import EventCard from "components/events/EventCard";
 import Loading from "components/Loading";
 import Loading from "components/Loading";
-import EventDetail from "./EventDetail";
+import InfiniteScroll from "react-infinite-scroll-component";
+import Dropdown from "components/Dropdown";
+import { useKubeEvents } from "components/events/useEvents";
+import { ChartType } from "shared/types";
+import _, { isEmpty, isObject } from "lodash";
+import SubEventsList from "components/events/SubEventsList";
 
 
-export type Event = {
-  event_id: string;
-  index: number;
-  info: string;
-  name: string;
-  status: number;
-  time: number;
-};
+const availableResourceTypes = [
+  { label: "Pods", value: "pod" },
+  { label: "HPA", value: "hpa" },
+];
 
 
-export type EventContainer = {
-  events: Event[];
-  name: string;
-  started_at: number;
-};
+const EventsTab: React.FC<{
+  controllers: Record<string, Record<string, any>>;
+}> = (props) => {
+  const { controllers } = props;
+  const [resourceType, setResourceType] = useState(availableResourceTypes[0]);
+  const [currentEvent, setCurrentEvent] = useState(null);
 
 
-type Props = {
-  currentChart: ChartType;
-};
+  const [selectedControllerKey, setSelectedControllerKey] = useState(null);
 
 
-const REFRESH_TIME = 15000;
+  const [hasControllers, setHasControllers] = useState(null);
 
 
-const EventsTab: React.FunctionComponent<Props> = (props) => {
-  const { currentCluster, currentProject } = useContext(Context);
-  const [isLoading, setIsLoading] = useState(true);
-  const [isError, setIsError] = useState(false);
-  const [shouldRequest, setShouldRequest] = useState(true);
-  const [eventData, setEventData] = useState<EventContainer[]>([]); // most recent event is last
-  const [selectedEvent, setSelectedEvent] = useState<number | null>(null);
+  const controllerOptions = useMemo(() => {
+    if (typeof controllers !== "object") {
+      return [];
+    }
 
 
-  // sort by time, ensure sequences are monotonically increasing by time, collapse by id
-  const filterData = (data: Event[]) => {
-    data = data.sort((a, b) => a.time - b.time);
+    return Object.entries(controllers).map(([key, value]) => ({
+      label: value?.metadata?.name,
+      value: key,
+    }));
+  }, [controllers]);
 
 
-    if (data.length == 0) return;
+  const currentControllerOption = useMemo(() => {
+    return (
+      controllerOptions?.find((c) => c.value === selectedControllerKey) ||
+      controllerOptions[0]
+    );
+  }, [selectedControllerKey, controllerOptions]);
 
 
-    let seq: Event[][] = [];
-    let cur: Event[] = [data[0]];
+  const selectedController = controllers[currentControllerOption?.value];
 
 
-    for (let i = 1; i < data.length; ++i) {
-      if (data[i].index < data[i - 1].index) {
-        seq.push(cur);
-        cur = [];
-      }
-      cur.push(data[i]);
-    }
-    if (cur) seq.push(cur);
+  const {
+    isLoading,
+    hasPorterAgent,
+    triggerInstall,
+    kubeEvents,
+    loadMoreEvents,
+    hasMore,
+  } = useKubeEvents({
+    resourceType: resourceType.value as any,
+    ownerName: selectedController?.metadata?.name,
+    ownerType: selectedController?.kind,
+    shouldWaitForOwner: true,
+  });
 
 
-    let ret: EventContainer[] = [];
-    seq.forEach((j) => {
-      j.push({
-        event_id: "",
-        index: 0,
-        info: "",
-        name: "",
-        status: 0,
-        time: 0,
-      });
+  useEffect(() => {
+    let timer: NodeJS.Timeout = null;
 
 
-      let fin: EventContainer = {
-        events: [],
-        name: "Deployment",
-        started_at: j[0].time,
-      };
-      for (let i = 0; i < j.length - 1; ++i) {
-        if (j[i].event_id != j[i + 1].event_id) {
-          fin.events.push(j[i]);
-        }
+    const checkControllers = (counter = 0) => {
+      if (timer !== null) {
+        clearTimeout(timer);
       }
       }
-      ret.push(fin);
-    });
 
 
-    setEventData(ret);
-  };
-
-  useEffect(() => {
-    const getData = () => {
-      if (!shouldRequest) return;
-      setShouldRequest(false);
-      api
-        .getReleaseSteps(
-          "<token>",
-          {},
-          {
-            cluster_id: currentCluster.id,
-            namespace: props.currentChart.namespace,
-            id: currentProject.id,
-            name: props.currentChart.name,
-          }
-        )
-        .then((data) => {
-          setIsLoading(false);
-          filterData(data.data);
-        })
-        .catch((err) => {
-          setIsError(true);
-        })
-        .finally(() => {
-          setShouldRequest(true);
-        });
+      if (isEmpty(controllers) && counter === 5) {
+        clearTimeout(timer);
+        setHasControllers(false);
+      } else {
+        if (isEmpty(controllers)) {
+          timer = setTimeout(() => {
+            checkControllers(counter + 1);
+          }, 2000);
+        } else {
+          setHasControllers(true);
+        }
+      }
     };
     };
 
 
-    getData();
-    const id = window.setInterval(getData, REFRESH_TIME);
+    checkControllers();
 
 
     return () => {
     return () => {
-      setIsLoading(true);
-      window.clearInterval(id);
+      if (timer !== null) {
+        clearTimeout(timer);
+      }
     };
     };
-  }, [currentProject, currentCluster, props.currentChart]);
+  }, [controllers]);
 
 
-  if (isError) {
-    return <Placeholder>Error loading events.</Placeholder>;
+  if (isLoading && hasControllers === null) {
+    return (
+      <Placeholder>
+        <Loading />
+      </Placeholder>
+    );
   }
   }
 
 
-  if (isLoading) {
+  if (!hasControllers) {
     return (
     return (
       <Placeholder>
       <Placeholder>
-        <Loading />
+        <i className="material-icons">search</i>
+        We coulnd't find any controllers for this application.
       </Placeholder>
       </Placeholder>
     );
     );
   }
   }
 
 
-  if (eventData.length === 0) {
+  if (!hasPorterAgent) {
     return (
     return (
       <Placeholder>
       <Placeholder>
-        <i className="material-icons">category</i>
-        No application events found.
+        <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
+          on your cluster.
+          <InstallPorterAgentButton onClick={() => triggerInstall()}>
+            <i className="material-icons">add</i> Install Porter agent
+          </InstallPorterAgentButton>
+        </div>
       </Placeholder>
       </Placeholder>
     );
     );
   }
   }
 
 
-  if (selectedEvent !== null) {
+  if (currentEvent) {
     return (
     return (
-      <EventDetail
-        container={eventData[selectedEvent]}
-        resetSelection={() => {
-          setSelectedEvent(null);
-          return null;
-        }}
+      <SubEventsList
+        event={currentEvent}
+        clearSelectedEvent={() => setCurrentEvent(null)}
       />
       />
     );
     );
   }
   }
 
 
   return (
   return (
-    <EventsGrid>
-      {eventData
-        .slice(0)
-        .reverse()
-        .map((dat, i) => {
-          console.log(dat.started_at);
-          return (
-            <React.Fragment key={dat.started_at}>
-              <EventCard
-                event={dat.events[dat.events.length - 1]}
-                selectEvent={() => {
-                  setSelectedEvent(eventData.length - i - 1);
-                }}
-                overrideName={"Deployment"}
+    <EventsPageWrapper>
+      {kubeEvents.length > 0 ? (
+        <>
+          <ControlRow>
+            {/*
+              <Dropdown
+                selectedOption={resourceType}
+                options={availableResourceTypes}
+                onSelect={(o) => setResourceType({ ...o, value: o.value as string })}
               />
               />
-            </React.Fragment>
-          );
-        })}
-    </EventsGrid>
+              */}
+            <Label>Controller -</Label>
+            <Dropdown
+              selectedOption={currentControllerOption}
+              options={controllerOptions}
+              onSelect={(o) => setSelectedControllerKey(o?.value)}
+            />
+          </ControlRow>
+
+          <InfiniteScroll
+            dataLength={kubeEvents.length}
+            next={loadMoreEvents}
+            hasMore={hasMore}
+            loader={<h4>Loading...</h4>}
+            scrollableTarget="HomeViewWrapper"
+          >
+            <EventsGrid>
+              {kubeEvents.map((event, i) => {
+                return (
+                  <React.Fragment key={i}>
+                    <EventCard
+                      event={event as any}
+                      selectEvent={() => {
+                        setCurrentEvent(event);
+                      }}
+                    />
+                  </React.Fragment>
+                );
+              })}
+            </EventsGrid>
+          </InfiniteScroll>
+        </>
+      ) : (
+        <Placeholder>
+          <i className="material-icons">search</i>
+          No matching events were found.
+        </Placeholder>
+      )}
+    </EventsPageWrapper>
   );
   );
 };
 };
 
 
 export default EventsTab;
 export default EventsTab;
 
 
+const Label = styled.div`
+  color: #ffffff44;
+  margin-right: 8px;
+  font-size: 13px;
+`;
+
+const ControlRow = styled.div`
+  display: flex;
+  align-items: center;
+  margin-bottom: 30px;
+  padding-left: 0px;
+  font-size: 13px;
+`;
+
 const EventsPageWrapper = styled.div`
 const EventsPageWrapper = styled.div`
-  margin-top: 35px;
-  padding-bottom: 80px;
+  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 EventsGrid = styled.div`
+  display: grid;
+  grid-row-gap: 15px;
+  grid-template-columns: 1;
 `;
 `;
 
 
 const InstallPorterAgentButton = styled.button`
 const InstallPorterAgentButton = styled.button`
@@ -191,12 +233,12 @@ const InstallPorterAgentButton = styled.button`
   cursor: pointer;
   cursor: pointer;
   font-family: "Work Sans", sans-serif;
   font-family: "Work Sans", sans-serif;
   border: none;
   border: none;
-  border-radius: 20px;
+  border-radius: 5px;
   color: white;
   color: white;
   height: 35px;
   height: 35px;
   padding: 0px 8px;
   padding: 0px 8px;
   padding-bottom: 1px;
   padding-bottom: 1px;
-  margin-top: 10px;
+  margin-top: 20px;
   font-weight: 500;
   font-weight: 500;
   padding-right: 15px;
   padding-right: 15px;
   overflow: hidden;
   overflow: hidden;
@@ -205,14 +247,11 @@ const InstallPorterAgentButton = styled.button`
   box-shadow: 0 5px 8px 0px #00000010;
   box-shadow: 0 5px 8px 0px #00000010;
   cursor: ${(props: { disabled?: boolean }) =>
   cursor: ${(props: { disabled?: boolean }) =>
     props.disabled ? "not-allowed" : "pointer"};
     props.disabled ? "not-allowed" : "pointer"};
-
   background: ${(props: { disabled?: boolean }) =>
   background: ${(props: { disabled?: boolean }) =>
-    props.disabled ? "#aaaabbee" : "#616FEEcc"};
+    props.disabled ? "#aaaabbee" : "#5561C0"};
   :hover {
   :hover {
-    background: ${(props: { disabled?: boolean }) =>
-      props.disabled ? "" : "#505edddd"};
+    filter: ${(props) => (!props.disabled ? "brightness(120%)" : "")};
   }
   }
-
   > i {
   > i {
     color: white;
     color: white;
     width: 18px;
     width: 18px;
@@ -228,18 +267,22 @@ const InstallPorterAgentButton = styled.button`
 `;
 `;
 
 
 const Placeholder = styled.div`
 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%;
   width: 100%;
-  min-height: 300px;
-  height: 40vh;
   display: flex;
   display: flex;
   align-items: center;
   align-items: center;
   justify-content: center;
   justify-content: center;
-  color: #ffffff44;
-  font-size: 14px;
 
 
   > i {
   > i {
     font-size: 18px;
     font-size: 18px;
-    margin-right: 10px;
+    margin-right: 8px;
   }
   }
 `;
 `;
 
 
@@ -249,16 +292,3 @@ const Header = styled.div`
   font-size: 16px;
   font-size: 16px;
   margin-bottom: 15px;
   margin-bottom: 15px;
 `;
 `;
-
-const Spinner = styled.img`
-  width: 15px;
-  height: 15px;
-  margin-right: 12px;
-  margin-bottom: -2px;
-`;
-
-const EventsGrid = styled.div`
-  display: grid;
-  grid-row-gap: 15px;
-  grid-template-columns: 1;
-`;

+ 25 - 28
dashboard/src/main/home/cluster-dashboard/expanded-chart/status/StatusSection.tsx

@@ -65,7 +65,9 @@ const StatusSectionFC: React.FunctionComponent<Props> = ({
         setControllers([]);
         setControllers([]);
         setIsLoading(false);
         setIsLoading(false);
       });
       });
-    return () => (isSubscribed = false);
+    return () => {
+      isSubscribed = false;
+    };
   }, [currentProject, currentCluster, setCurrentError, currentChart]);
   }, [currentProject, currentCluster, setCurrentError, currentChart]);
 
 
   const renderLogs = () => {
   const renderLogs = () => {
@@ -133,31 +135,27 @@ const StatusSectionFC: React.FunctionComponent<Props> = ({
 
 
   return (
   return (
     <>
     <>
-      {
-        fullscreen ? (
-          <FullScreen>
-            <AbsoluteTitle>
-              <BackButton
-                onClick={setFullScreenLogs}
-              >
-                <i className="material-icons">navigate_before</i>
-              </BackButton>
-              Status ({currentChart.name})
-            </AbsoluteTitle>
-            <FullScreenButton top="70px" onClick={setFullScreenLogs}>
-              <i className="material-icons">close_fullscreen</i>
-            </FullScreenButton>
-            {renderStatusSection()}
-          </FullScreen>
-        ) : (
-          <StyledStatusSection>
-            <FullScreenButton onClick={setFullScreenLogs}>
-              <i className="material-icons">open_in_full</i>
-            </FullScreenButton>
-            {renderStatusSection()}
-          </StyledStatusSection>
-        )
-      }
+      {fullscreen ? (
+        <FullScreen>
+          <AbsoluteTitle>
+            <BackButton onClick={setFullScreenLogs}>
+              <i className="material-icons">navigate_before</i>
+            </BackButton>
+            Status ({currentChart.name})
+          </AbsoluteTitle>
+          <FullScreenButton top="70px" onClick={setFullScreenLogs}>
+            <i className="material-icons">close_fullscreen</i>
+          </FullScreenButton>
+          {renderStatusSection()}
+        </FullScreen>
+      ) : (
+        <StyledStatusSection>
+          <FullScreenButton onClick={setFullScreenLogs}>
+            <i className="material-icons">open_in_full</i>
+          </FullScreenButton>
+          {renderStatusSection()}
+        </StyledStatusSection>
+      )}
     </>
     </>
   );
   );
 };
 };
@@ -166,7 +164,7 @@ export default StatusSectionFC;
 
 
 const FullScreenButton = styled.div<{ top?: string }>`
 const FullScreenButton = styled.div<{ top?: string }>`
   position: absolute;
   position: absolute;
-  top: ${props => props.top || "10px"};
+  top: ${(props) => props.top || "10px"};
   right: 10px;
   right: 10px;
   width: 24px;
   width: 24px;
   height: 24px;
   height: 24px;
@@ -218,7 +216,6 @@ const BackButtonImg = styled.img`
   opacity: 0.75;
   opacity: 0.75;
 `;
 `;
 
 
-
 const AbsoluteTitle = styled.div`
 const AbsoluteTitle = styled.div`
   position: absolute;
   position: absolute;
   top: 0px;
   top: 0px;

+ 3 - 0
dashboard/src/main/home/launch/launch-flow/LaunchFlow.tsx

@@ -64,6 +64,7 @@ const LaunchFlow: React.FC<PropsType> = (props) => {
   const [folderPath, setFolderPath] = useState(null);
   const [folderPath, setFolderPath] = useState(null);
   const [selectedRegistry, setSelectedRegistry] = useState(null);
   const [selectedRegistry, setSelectedRegistry] = useState(null);
   const [shouldCreateWorkflow, setShouldCreateWorkflow] = useState(true);
   const [shouldCreateWorkflow, setShouldCreateWorkflow] = useState(true);
+  const [buildConfig, setBuildConfig] = useState();
 
 
   const generateRandomName = () => {
   const generateRandomName = () => {
     const randomTemplateName = randomWords({ exactly: 3, join: "-" });
     const randomTemplateName = randomWords({ exactly: 3, join: "-" });
@@ -268,6 +269,7 @@ const LaunchFlow: React.FC<PropsType> = (props) => {
           template_version: props.currentTemplate?.currentVersion || "latest",
           template_version: props.currentTemplate?.currentVersion || "latest",
           name: release_name,
           name: release_name,
           github_action_config: githubActionConfig,
           github_action_config: githubActionConfig,
+          build_config: buildConfig,
         },
         },
         {
         {
           id: currentProject.id,
           id: currentProject.id,
@@ -328,6 +330,7 @@ const LaunchFlow: React.FC<PropsType> = (props) => {
           setProcfilePath={setProcfilePath}
           setProcfilePath={setProcfilePath}
           selectedRegistry={selectedRegistry}
           selectedRegistry={selectedRegistry}
           setSelectedRegistry={setSelectedRegistry}
           setSelectedRegistry={setSelectedRegistry}
+          setBuildConfig={setBuildConfig}
         />
         />
       );
       );
     }
     }

+ 3 - 0
dashboard/src/main/home/launch/launch-flow/SourcePage.tsx

@@ -45,6 +45,7 @@ type PropsType = RouteComponentProps & {
   setFolderPath: (x: string) => void;
   setFolderPath: (x: string) => void;
   selectedRegistry: any;
   selectedRegistry: any;
   setSelectedRegistry: (x: string) => void;
   setSelectedRegistry: (x: string) => void;
+  setBuildConfig: (x: any) => void;
 };
 };
 
 
 type StateType = {};
 type StateType = {};
@@ -144,6 +145,7 @@ class SourcePage extends Component<PropsType, StateType> {
       setFolderPath,
       setFolderPath,
       selectedRegistry,
       selectedRegistry,
       setSelectedRegistry,
       setSelectedRegistry,
+      setBuildConfig,
     } = this.props;
     } = this.props;
     return (
     return (
       <StyledSourceBox>
       <StyledSourceBox>
@@ -207,6 +209,7 @@ class SourcePage extends Component<PropsType, StateType> {
           }}
           }}
           setSelectedRegistry={setSelectedRegistry}
           setSelectedRegistry={setSelectedRegistry}
           selectedRegistry={selectedRegistry}
           selectedRegistry={selectedRegistry}
+          setBuildConfig={setBuildConfig}
         />
         />
         <br />
         <br />
       </StyledSourceBox>
       </StyledSourceBox>

+ 4 - 5
dashboard/src/main/home/modals/DeleteNamespaceModal.tsx

@@ -1,6 +1,5 @@
 import React, { useContext, useState } from "react";
 import React, { useContext, useState } from "react";
 import styled from "styled-components";
 import styled from "styled-components";
-import close from "assets/close.png";
 
 
 import api from "shared/api";
 import api from "shared/api";
 import { Context } from "shared/Context";
 import { Context } from "shared/Context";
@@ -19,7 +18,7 @@ const DeleteNamespaceModal = () => {
   const [namespaceNameForDelition, setNamespaceNameForDelition] = useState("");
   const [namespaceNameForDelition, setNamespaceNameForDelition] = useState("");
   const [status, setStatus] = useState<string>(null as string);
   const [status, setStatus] = useState<string>(null as string);
   const deleteNamespace = () => {
   const deleteNamespace = () => {
-    if (namespaceNameForDelition !== currentModalData.metadata.name) {
+    if (namespaceNameForDelition !== currentModalData?.metadata?.name) {
       setStatus("Please enter the name of this namespace to confirm deletion");
       setStatus("Please enter the name of this namespace to confirm deletion");
       return;
       return;
     }
     }
@@ -27,7 +26,7 @@ const DeleteNamespaceModal = () => {
     api
     api
       .deleteNamespace(
       .deleteNamespace(
         "<token>",
         "<token>",
-        { name: currentModalData.metadata.name },
+        { name: currentModalData?.metadata?.name },
         {
         {
           id: currentProject.id,
           id: currentProject.id,
           cluster_id: currentCluster.id,
           cluster_id: currentCluster.id,
@@ -50,7 +49,7 @@ const DeleteNamespaceModal = () => {
     <>
     <>
       <Subtitle>
       <Subtitle>
         Please insert the name of the namespace to delete it:
         Please insert the name of the namespace to delete it:
-        <DangerText>{" " + currentModalData.metadata.name}</DangerText>
+        <DangerText>{" " + currentModalData?.metadata?.name}</DangerText>
       </Subtitle>
       </Subtitle>
 
 
       <InputWrapper>
       <InputWrapper>
@@ -61,7 +60,7 @@ const DeleteNamespaceModal = () => {
           type="string"
           type="string"
           value={namespaceNameForDelition}
           value={namespaceNameForDelition}
           setValue={(x: string) => setNamespaceNameForDelition(x)}
           setValue={(x: string) => setNamespaceNameForDelition(x)}
-          placeholder={currentModalData.metadata.name}
+          placeholder={currentModalData?.metadata?.name}
           width="480px"
           width="480px"
         />
         />
       </InputWrapper>
       </InputWrapper>

+ 8 - 1
dashboard/src/main/home/modals/EditInviteOrCollaboratorModal.tsx

@@ -10,9 +10,16 @@ import { setTimeout } from "timers";
 const EditCollaboratorModal = () => {
 const EditCollaboratorModal = () => {
   const {
   const {
     setCurrentModal,
     setCurrentModal,
-    currentModalData: { user, isInvite, refetchCallerData },
+    currentModalData,
     currentProject: { id: project_id },
     currentProject: { id: project_id },
   } = useContext(Context);
   } = useContext(Context);
+
+  const { user, isInvite, refetchCallerData } = {
+    user: currentModalData?.user,
+    isInvite: currentModalData?.isInvite,
+    refetchCallerData: currentModalData?.refetchCallerData,
+  };
+
   const [status, setStatus] = useState<undefined | string>();
   const [status, setStatus] = useState<undefined | string>();
   const [selectedRole, setSelectedRole] = useState("");
   const [selectedRole, setSelectedRole] = useState("");
   const [roleList, setRoleList] = useState([]);
   const [roleList, setRoleList] = useState([]);

+ 7 - 5
dashboard/src/main/home/modals/SkipProvisioningModal.tsx

@@ -9,7 +9,7 @@ import styled from "styled-components";
  * will open this modal to let user skip onboarding and keep using porter.
  * will open this modal to let user skip onboarding and keep using porter.
  */
  */
 const SkipOnboardingModal = () => {
 const SkipOnboardingModal = () => {
-  const { currentModalData, setCurrentModal } = useContext(Context);
+  const { currentModalData, setHasFinishedOnboarding } = useContext(Context);
 
 
   return (
   return (
     <>
     <>
@@ -22,10 +22,12 @@ const SkipOnboardingModal = () => {
         <ActionButton
         <ActionButton
           text="Yes, skip setup"
           text="Yes, skip setup"
           color="#616FEEcc"
           color="#616FEEcc"
-          onClick={() =>
-            typeof currentModalData?.skipOnboarding === "function" &&
-            currentModalData.skipOnboarding()
-          }
+          onClick={() => {
+            if (typeof currentModalData?.skipOnboarding === "function") {
+              currentModalData.skipOnboarding();
+            }
+            setHasFinishedOnboarding(true);
+          }}
           status={""}
           status={""}
           clearPosition
           clearPosition
         />
         />

+ 60 - 48
dashboard/src/main/home/navbar/Help.tsx

@@ -4,8 +4,7 @@ import styled from "styled-components";
 import { Context } from "shared/Context";
 import { Context } from "shared/Context";
 import discordLogo from "../../../assets/discord.svg";
 import discordLogo from "../../../assets/discord.svg";
 
 
-type PropsType = {
-};
+type PropsType = {};
 
 
 type StateType = {
 type StateType = {
   showHelpDropdown: boolean;
   showHelpDropdown: boolean;
@@ -13,7 +12,7 @@ type StateType = {
 
 
 export default class Help extends Component<PropsType, StateType> {
 export default class Help extends Component<PropsType, StateType> {
   state = {
   state = {
-      showHelpDropdown: false,
+    showHelpDropdown: false,
   };
   };
 
 
   renderHelpDropdown = () => {
   renderHelpDropdown = () => {
@@ -27,28 +26,29 @@ export default class Help extends Component<PropsType, StateType> {
               })
               })
             }
             }
           />
           />
-          <Dropdown
-            dropdownWidth="155px"
-            dropdownMaxHeight="300px"
-          >
-            <Option onClick={()=> {
-                window.open('https://docs.porter.run', '_blank').focus();}
-            }>
-            <i className="material-icons-outlined">book</i>
-                Documentation
+          <Dropdown dropdownWidth="155px" dropdownMaxHeight="300px">
+            <Option
+              onClick={() => {
+                window.open("https://docs.porter.run", "_blank").focus();
+              }}
+            >
+              <i className="material-icons-outlined">book</i>
+              Documentation
             </Option>
             </Option>
-            <Line/>
-            <Option onClick={() => {
-              window.open('https://discord.gg/Vbse9vJtPU', '_blank').focus();
-            }}>
-            <Icon src={discordLogo} />
+            <Line />
+            <Option
+              onClick={() => {
+                window.open("https://discord.gg/Vbse9vJtPU", "_blank").focus();
+              }}
+            >
+              <Icon src={discordLogo} />
               Community
               Community
             </Option>
             </Option>
-            <Line/>
-            <Option id={'intercom_help'}>
-            <i className="material-icons-outlined">message</i>
-                Message us
-            </Option>            
+            <Line />
+            <Option id={"intercom_help"}>
+              <i className="material-icons-outlined">message</i>
+              Message us
+            </Option>
           </Dropdown>
           </Dropdown>
         </>
         </>
       );
       );
@@ -77,31 +77,43 @@ export default class Help extends Component<PropsType, StateType> {
 Help.contextType = Context;
 Help.contextType = Context;
 
 
 const Option = styled.div`
 const Option = styled.div`
-    margin-left: 15px;
-    font-size: 13px;
-    display: flex;
-    align-items: center;
-    justify-content: flex-start;
-    width: 120px;
-    height: 40px;
-    color: #ffffff88;
+  margin-left: 12px;
+  font-size: 13px;
+  display: flex;
+  align-items: center;
+  justify-content: flex-start;
+  width: 120px;
+  height: 40px;
+  color: #ffffff88;
+  cursor: pointer;
+  > i {
+    opacity: 50%;
+    color: white;
+    margin-right: 9px;
+    font-size: 18px;
     cursor: pointer;
     cursor: pointer;
+  }
+
+  :hover {
+    color: #ffffff;
+
+    > img {
+      opacity: 100%;
+    }
+
     > i {
     > i {
-        opacity: 50%;
-        color: white;
-        margin-right: 7px;
-        font-size: 20px;
-        cursor: pointer;
+      opacity: 100%;
     }
     }
-`
+  }
+`;
 
 
 const Line = styled.div`
 const Line = styled.div`
-    height: 1px;
-    z-index: 0;
-    left: 0;
-    background: #aaaabb55;
-    width: 100%;
-`
+  height: 1px;
+  z-index: 0;
+  left: 0;
+  background: #aaaabb55;
+  width: 100%;
+`;
 
 
 const CloseOverlay = styled.div`
 const CloseOverlay = styled.div`
   position: fixed;
   position: fixed;
@@ -203,9 +215,9 @@ const FeedbackButton = styled(NavButton)`
 `;
 `;
 
 
 const Icon = styled.img`
 const Icon = styled.img`
-    margin-left: -2px;
-    height: 25px;
-    width: 25px;
-    opacity: 50%;
-    margin-right: 5px;
-`
+  margin-left: -2px;
+  height: 22px;
+  width: 22px;
+  opacity: 50%;
+  margin-right: 7px;
+`;

+ 11 - 11
dashboard/src/main/home/navbar/Navbar.tsx

@@ -62,7 +62,7 @@ class Navbar extends Component<PropsType, StateType> {
   render() {
   render() {
     return (
     return (
       <StyledNavbar>
       <StyledNavbar>
-        <Help/>
+        <Help />
         {this.renderFeedbackButton()}
         {this.renderFeedbackButton()}
         <NavButton
         <NavButton
           selected={this.state.showDropdown}
           selected={this.state.showDropdown}
@@ -245,18 +245,18 @@ const StyledNavbar = styled.div`
 `;
 `;
 
 
 const HelpIcon = styled.div`
 const HelpIcon = styled.div`
-> a {
-  > i {
-    font-size: 18px;
-    margin-left: 8px;
-    margin-top: 2px;
-    color: #8590ff;
-    :hover {
-      color: #aaaabb;
+  > a {
+    > i {
+      font-size: 18px;
+      margin-left: 8px;
+      margin-top: 2px;
+      color: #8590ff;
+      :hover {
+        color: #aaaabb;
+      }
     }
     }
   }
   }
-}
-`
+`;
 
 
 const NavButton = styled.a`
 const NavButton = styled.a`
   display: flex;
   display: flex;

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

@@ -135,7 +135,7 @@ const Onboarding = () => {
 
 
       const hasClusters = Array.isArray(clusters) && clusters.length;
       const hasClusters = Array.isArray(clusters) && clusters.length;
 
 
-      if (hasClusters) {
+      if (hasClusters && !context.hasFinishedOnboarding) {
         setCurrentModal("SkipOnboardingModal", { skipOnboarding });
         setCurrentModal("SkipOnboardingModal", { skipOnboarding });
       }
       }
     } catch (error) {
     } catch (error) {

+ 19 - 7
dashboard/src/main/home/onboarding/components/ProviderSelector.tsx

@@ -1,4 +1,4 @@
-import React, { useMemo, useState } from "react";
+import React, { useMemo, useRef, useState } from "react";
 import { integrationList } from "shared/common";
 import { integrationList } from "shared/common";
 import styled from "styled-components";
 import styled from "styled-components";
 import { SupportedProviders } from "../types";
 import { SupportedProviders } from "../types";
@@ -13,6 +13,7 @@ export type ProviderSelectorProps = {
     icon: string;
     icon: string;
     label: string;
     label: string;
   }[];
   }[];
+  defaultOption?: string;
 };
 };
 
 
 export const registryOptions = [
 export const registryOptions = [
@@ -68,22 +69,33 @@ export const provisionerOptionsWithExternal = [
 const ProviderSelector: React.FC<ProviderSelectorProps> = ({
 const ProviderSelector: React.FC<ProviderSelectorProps> = ({
   selectProvider,
   selectProvider,
   options,
   options,
+  defaultOption,
 }) => {
 }) => {
-  const [provider, setProvider] = useState(() => {
-    if (options.find((o) => o.value === "skip")) {
-      return "skip";
+  const [provider, setProvider] = useState(null);
+  const [isDirty, setIsDirty] = useState(false);
+
+  const activeProvider = useMemo(() => {
+    if (!isDirty || !provider) {
+      if (typeof defaultOption === "string") {
+        return defaultOption;
+      }
+      if (options.find((o) => o.value === "skip")) {
+        return "skip";
+      }
     }
     }
-    return null;
-  });
+
+    return provider;
+  }, [provider, isDirty, defaultOption]);
 
 
   return (
   return (
     <>
     <>
       <Br />
       <Br />
       <Selector
       <Selector
-        activeValue={provider}
+        activeValue={activeProvider}
         options={options}
         options={options}
         placeholder="Select a cloud provider"
         placeholder="Select a cloud provider"
         setActiveValue={(provider) => {
         setActiveValue={(provider) => {
+          setIsDirty(true);
           setProvider(provider);
           setProvider(provider);
           selectProvider(provider as SupportedProviders);
           selectProvider(provider as SupportedProviders);
         }}
         }}

+ 15 - 0
dashboard/src/main/home/onboarding/state/StateHandler.ts

@@ -76,6 +76,21 @@ export const StateHandler = proxy({
         skip: true,
         skip: true,
       };
       };
     },
     },
+    saveRegistryAndContinue: (data: any) => {
+      const serviceToProvider = {
+        ecr: "aws",
+        gcr: "gcp",
+        dcr: "do",
+      };
+      const connectedRegistry = {
+        skip: false,
+        provider: (serviceToProvider as any)[data?.service],
+        credentials: {
+          id: data?.id,
+        },
+      };
+      StateHandler.connected_registry = connectedRegistry;
+    },
     saveRegistryProvider: (provider: string) => {
     saveRegistryProvider: (provider: string) => {
       if (provider === StateHandler.connected_registry?.provider) {
       if (provider === StateHandler.connected_registry?.provider) {
         return;
         return;

+ 11 - 3
dashboard/src/main/home/onboarding/state/StepHandler.ts

@@ -1,5 +1,6 @@
-import { useEffect } from "react";
+import { useContext, useEffect } from "react";
 import { useLocation } from "react-router";
 import { useLocation } from "react-router";
+import { Context } from "shared/Context";
 import { useRouting } from "shared/routing";
 import { useRouting } from "shared/routing";
 import { proxy, useSnapshot } from "valtio";
 import { proxy, useSnapshot } from "valtio";
 import { StepKey, Steps } from "../types";
 import { StepKey, Steps } from "../types";
@@ -16,15 +17,17 @@ type Step = {
       skip?: string;
       skip?: string;
       continue?: string;
       continue?: string;
       go_back?: string;
       go_back?: string;
+      continue_with_current?: string;
     };
     };
   };
   };
 };
 };
 
 
-export type Action = "skip" | "continue" | "go_back";
+export type Action = "skip" | "continue" | "go_back" | "continue_with_current";
 type ActionHandler = {
 type ActionHandler = {
   skip?: string;
   skip?: string;
   continue: string;
   continue: string;
   go_back?: string;
   go_back?: string;
+  continue_with_current?: string;
 };
 };
 
 
 export type FlowType = {
 export type FlowType = {
@@ -53,12 +56,14 @@ const flow: FlowType = {
       on: {
       on: {
         skip: "provision_resources",
         skip: "provision_resources",
         continue: "connect_registry.credentials",
         continue: "connect_registry.credentials",
+        continue_with_current: "provision_resources",
         go_back: "connect_source",
         go_back: "connect_source",
       },
       },
       execute: {
       execute: {
         on: {
         on: {
           skip: "skipRegistryConnection",
           skip: "skipRegistryConnection",
           continue: "saveRegistryProvider",
           continue: "saveRegistryProvider",
+          continue_with_current: "saveRegistryAndContinue",
         },
         },
       },
       },
       substeps: {
       substeps: {
@@ -112,7 +117,7 @@ const flow: FlowType = {
          * has a proper way of listing the registries and
          * has a proper way of listing the registries and
          * manage them inside the step
          * manage them inside the step
          */
          */
-        // go_back: "connect_registry",
+        go_back: "connect_registry",
       },
       },
       execute: {
       execute: {
         on: {
         on: {
@@ -283,12 +288,15 @@ export const useSteps = (isParentLoading?: boolean) => {
   const snap = useSnapshot(StepHandler);
   const snap = useSnapshot(StepHandler);
   const location = useLocation();
   const location = useLocation();
   const { pushFiltered } = useRouting();
   const { pushFiltered } = useRouting();
+  const { setHasFinishedOnboarding } = useContext(Context);
+
   useEffect(() => {
   useEffect(() => {
     if (isParentLoading) {
     if (isParentLoading) {
       return;
       return;
     }
     }
     if (snap.currentStepName === "clean_up") {
     if (snap.currentStepName === "clean_up") {
       StepHandler.actions.clearState();
       StepHandler.actions.clearState();
+      setHasFinishedOnboarding(true);
     }
     }
     pushFiltered(snap.currentStep.url, ["tab"]);
     pushFiltered(snap.currentStep.url, ["tab"]);
   }, [location.pathname, snap.currentStep?.url, isParentLoading]);
   }, [location.pathname, snap.currentStep?.url, isParentLoading]);

+ 106 - 4
dashboard/src/main/home/onboarding/steps/ConnectRegistry/ConnectRegistry.tsx

@@ -1,7 +1,7 @@
 import Helper from "components/form-components/Helper";
 import Helper from "components/form-components/Helper";
 import SaveButton from "components/SaveButton";
 import SaveButton from "components/SaveButton";
 import TitleSection from "components/TitleSection";
 import TitleSection from "components/TitleSection";
-import React from "react";
+import React, { useEffect, useMemo, useState } from "react";
 import { useParams } from "react-router";
 import { useParams } from "react-router";
 
 
 import styled from "styled-components";
 import styled from "styled-components";
@@ -13,16 +13,66 @@ import backArrow from "assets/back_arrow.png";
 import FormFlowWrapper from "./forms/FormFlow";
 import FormFlowWrapper from "./forms/FormFlow";
 import { OFState } from "../../state";
 import { OFState } from "../../state";
 import { useSnapshot } from "valtio";
 import { useSnapshot } from "valtio";
+import api from "shared/api";
+import Loading from "components/Loading";
+import { integrationList } from "shared/common";
+import Registry from "./components/Registry";
 
 
 const ConnectRegistry: React.FC<{}> = ({}) => {
 const ConnectRegistry: React.FC<{}> = ({}) => {
   const snap = useSnapshot(OFState);
   const snap = useSnapshot(OFState);
   const { step } = useParams<any>();
   const { step } = useParams<any>();
+  const [connectedRegistries, setConnectedRegistries] = useState(null);
+  const [isLoading, setIsLoading] = useState(true);
 
 
   const currentProvider = snap.StateHandler.connected_registry?.provider;
   const currentProvider = snap.StateHandler.connected_registry?.provider;
 
 
   const enableGoBack =
   const enableGoBack =
     snap.StepHandler.canGoBack && !snap.StepHandler.isSubFlow;
     snap.StepHandler.canGoBack && !snap.StepHandler.isSubFlow;
 
 
+  useEffect(() => {
+    let hookState = { isSubscribed: true };
+
+    getRegistries(hookState);
+
+    return () => {
+      hookState.isSubscribed = false;
+    };
+  }, [snap.StateHandler?.project]);
+
+  const getRegistries = async (
+    props: { isSubscribed: boolean } = { isSubscribed: true }
+  ) => {
+    const projectId = snap.StateHandler?.project?.id;
+
+    if (typeof projectId !== "number") {
+      return;
+    }
+
+    setIsLoading(true);
+    try {
+      const res = await api.getProjectRegistries(
+        "<token>",
+        {},
+        { id: projectId }
+      );
+      const registries = res?.data;
+      if (props.isSubscribed) {
+        if (Array.isArray(registries)) {
+          setConnectedRegistries(registries);
+        }
+      }
+    } catch (error) {
+      console.error(error);
+      if (props.isSubscribed) {
+        setConnectedRegistries(null);
+      }
+    } finally {
+      if (props.isSubscribed) {
+        setIsLoading(false);
+      }
+    }
+  };
+
   const handleGoBack = () => {
   const handleGoBack = () => {
     OFState.actions.nextStep("go_back");
     OFState.actions.nextStep("go_back");
   };
   };
@@ -35,6 +85,27 @@ const ConnectRegistry: React.FC<{}> = ({}) => {
     provider !== "skip" && OFState.actions.nextStep("continue", provider);
     provider !== "skip" && OFState.actions.nextStep("continue", provider);
   };
   };
 
 
+  const handleContinueWithCurrent = () => {
+    const connectedRegistry = connectedRegistries[0];
+    OFState.actions.nextStep("continue_with_current", connectedRegistry);
+  };
+
+  const selectorOptions = useMemo(() => {
+    const options = [...registryOptions];
+    if (Array.isArray(connectedRegistries) && connectedRegistries.length) {
+      const newOptions = options.filter((o) => o.value !== "skip");
+      return [
+        {
+          value: "use_current",
+          label: "Continue with current",
+          icon: "",
+        },
+        ...newOptions,
+      ];
+    }
+    return options;
+  }, [connectedRegistries]);
+
   return (
   return (
     <Div>
     <Div>
       {enableGoBack && (
       {enableGoBack && (
@@ -62,22 +133,49 @@ const ConnectRegistry: React.FC<{}> = ({}) => {
           : "Link to an existing Docker registry or continue."}
           : "Link to an existing Docker registry or continue."}
       </Helper>
       </Helper>
 
 
-      {step ? (
+      {!isLoading && step ? (
         <FormFlowWrapper currentStep={step} />
         <FormFlowWrapper currentStep={step} />
       ) : (
       ) : (
         <>
         <>
           <ProviderSelector
           <ProviderSelector
+            defaultOption={
+              Array.isArray(connectedRegistries) && connectedRegistries.length
+                ? "use_current"
+                : "skip"
+            }
             selectProvider={(provider) => {
             selectProvider={(provider) => {
               if (provider !== "external") {
               if (provider !== "external") {
                 handleSelectProvider(provider);
                 handleSelectProvider(provider);
               }
               }
             }}
             }}
-            options={registryOptions}
+            options={selectorOptions}
           />
           />
+          {isLoading && <Loading />}
+
+          {!!connectedRegistries?.length && (
+            <IntegrationList>
+              {connectedRegistries.map((registry: any) => (
+                <Registry
+                  key={registry.name}
+                  registry={registry}
+                  onDelete={getRegistries}
+                />
+              ))}
+            </IntegrationList>
+          )}
           <NextStep
           <NextStep
             text="Continue"
             text="Continue"
             disabled={false}
             disabled={false}
-            onClick={() => handleSkip()}
+            onClick={() => {
+              if (
+                Array.isArray(connectedRegistries) &&
+                connectedRegistries.length
+              ) {
+                handleContinueWithCurrent();
+              } else {
+                handleSkip();
+              }
+            }}
             status={""}
             status={""}
             makeFlush={true}
             makeFlush={true}
             clearPosition={true}
             clearPosition={true}
@@ -92,6 +190,10 @@ const ConnectRegistry: React.FC<{}> = ({}) => {
 
 
 export default ConnectRegistry;
 export default ConnectRegistry;
 
 
+const IntegrationList = styled.div`
+  margin-top: 14px;
+`;
+
 const Div = styled.div`
 const Div = styled.div`
   width: 100%;
   width: 100%;
 `;
 `;

+ 193 - 0
dashboard/src/main/home/onboarding/steps/ConnectRegistry/components/Registry.tsx

@@ -0,0 +1,193 @@
+import Loading from "components/Loading";
+import { OFState } from "main/home/onboarding/state";
+import React, { useContext, useState } from "react";
+import api from "shared/api";
+import { integrationList } from "shared/common";
+import { Context } from "shared/Context";
+import styled from "styled-components";
+import { useSnapshot } from "valtio";
+
+const serviceToProvider: {
+  [key: string]: string;
+} = {
+  docr: "do",
+  ecr: "aws",
+  gcr: "gcp",
+};
+
+const Registry: React.FC<{ registry: any; onDelete: () => void }> = (props) => {
+  const { registry, onDelete } = props;
+  const service = serviceToProvider[registry?.service];
+  const icon = integrationList[service || registry?.service]?.icon;
+  const subtitle = integrationList[registry?.service]?.label;
+  const snap = useSnapshot(OFState);
+  const { setCurrentError } = useContext(Context);
+
+  const [isDeleting, setIsDeleting] = useState(false);
+  const [hasError, setHasError] = useState(false);
+
+  const deleteRegistry = async (id: number) => {
+    const projectId = snap.StateHandler?.project?.id;
+
+    if (typeof projectId !== "number") {
+      return;
+    }
+    setIsDeleting(true);
+    try {
+      await api.deleteRegistryIntegration(
+        "<token>",
+        {},
+        {
+          project_id: projectId,
+          registry_id: id,
+        }
+      );
+      onDelete();
+      setIsDeleting(false);
+    } catch (error) {
+      setIsDeleting(false);
+      setCurrentError(error);
+      setHasError(true);
+      setTimeout(() => setHasError(false), 1000);
+    }
+  };
+
+  return (
+    <React.Fragment key={registry.name}>
+      <Integration>
+        <MainRow disabled={false}>
+          <Flex>
+            <Icon src={icon && icon} />
+            <Description>
+              <Label>{registry?.name}</Label>
+              <IntegrationSubtitle>{subtitle}</IntegrationSubtitle>
+            </Description>
+          </Flex>
+          <MaterialIconTray disabled={false}>
+            {isDeleting && (
+              <I disabled>
+                <Loading height={"28px"} width="28px" />
+              </I>
+            )}
+            {hasError && (
+              <ErrorI className="material-icons">priority_high</ErrorI>
+            )}
+            {!hasError && !isDeleting && (
+              <I
+                className="material-icons"
+                onClick={() => deleteRegistry(registry?.id)}
+              >
+                delete
+              </I>
+            )}
+          </MaterialIconTray>
+        </MainRow>
+      </Integration>
+    </React.Fragment>
+  );
+};
+
+export default Registry;
+
+const Flex = styled.div`
+  display: flex;
+  align-items: center;
+  justify-content: center;
+`;
+
+const Integration = styled.div`
+  margin-left: -2px;
+  display: flex;
+  flex-direction: column;
+  background: #26282f;
+  margin-bottom: 15px;
+  border-radius: 8px;
+  box-shadow: 0 4px 15px 0px #00000055;
+`;
+
+const IntegrationSubtitle = styled.div`
+  color: #aaaabb;
+  font-size: 13px;
+  display: flex;
+  align-items: center;
+  padding-top: 5px;
+`;
+
+const Icon = styled.img`
+  width: 30px;
+  margin-right: 18px;
+`;
+
+const I = styled.i`
+  color: #ffffff44;
+  :hover {
+    cursor: ${(props: { disabled?: boolean }) =>
+      props.disabled ? "not-allowed" : "pointer"};
+  }
+`;
+
+const ErrorI = styled(I)`
+  color: #ed5f85;
+`;
+
+const MainRow = styled.div`
+  height: 70px;
+  width: 100%;
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  padding: 25px;
+  border-radius: 5px;
+  :hover {
+    background: ${(props: { disabled: boolean }) =>
+      props.disabled ? "" : "#ffffff11"};
+    > i {
+      background: ${(props: { disabled: boolean }) =>
+        props.disabled ? "" : "#ffffff11"};
+    }
+  }
+
+  > i {
+    border-radius: 20px;
+    font-size: 18px;
+    padding: 5px;
+    color: #ffffff44;
+    margin-right: -7px;
+    :hover {
+      background: ${(props: { disabled: boolean }) =>
+        props.disabled ? "" : "#ffffff11"};
+    }
+  }
+`;
+
+const MaterialIconTray = styled.div`
+  max-width: 60px;
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  > i {
+    background: #26282f;
+    border-radius: 20px;
+    font-size: 18px;
+    padding: 5px;
+    margin: 0 5px;
+
+    :hover {
+      background: ${(props: { disabled: boolean }) =>
+        props.disabled ? "" : "#ffffff11"};
+    }
+  }
+`;
+
+const Description = styled.div`
+  display: flex;
+  flex-direction: column;
+  margin: 0;
+  padding: 0;
+`;
+
+const Label = styled.div`
+  color: #ffffff;
+  font-size: 14px;
+  font-weight: 500;
+`;

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

@@ -315,11 +315,12 @@ class Sidebar extends Component<PropsType, StateType> {
           <br />
           <br />
 
 
           {this.renderProjectContents()}
           {this.renderProjectContents()}
-
+          {/*
           <DiscordButton href="https://discord.gg/34n7NN7FJ7" target="_blank">
           <DiscordButton href="https://discord.gg/34n7NN7FJ7" target="_blank">
             <Icon src={discordLogo} />
             <Icon src={discordLogo} />
             Join Our Discord
             Join Our Discord
           </DiscordButton>
           </DiscordButton>
+          */}
         </StyledSidebar>
         </StyledSidebar>
       </>
       </>
     );
     );

+ 79 - 0
dashboard/src/shared/api.tsx

@@ -321,6 +321,7 @@ const deployTemplate = baseApi<
     values?: any;
     values?: any;
     name: string;
     name: string;
     github_action_config?: FullActionConfigType;
     github_action_config?: FullActionConfigType;
+    build_config?: any;
   },
   },
   {
   {
     id: number;
     id: number;
@@ -635,6 +636,20 @@ const getJobPods = baseApi<
   return `/api/projects/${id}/clusters/${cluster_id}/namespaces/${namespace}/jobs/${name}/pods`;
   return `/api/projects/${id}/clusters/${cluster_id}/namespaces/${namespace}/jobs/${name}/pods`;
 });
 });
 
 
+const getPodByName = baseApi<
+  {},
+  {
+    project_id: number;
+    cluster_id: number;
+    namespace: string;
+    name: string;
+  }
+>(
+  "GET",
+  ({ project_id, cluster_id, namespace, name }) =>
+    `/api/projects/${project_id}/clusters/${cluster_id}/namespaces/${namespace}/pods/${name}`
+);
+
 const getMatchingPods = baseApi<
 const getMatchingPods = baseApi<
   {
   {
     namespace: string;
     namespace: string;
@@ -1132,6 +1147,63 @@ const getOnboardingRegistry = baseApi<
     `/api/projects/${project_id}/registries/${registry_connection_id}`
     `/api/projects/${project_id}/registries/${registry_connection_id}`
 );
 );
 
 
+const detectPorterAgent = baseApi<
+  {},
+  { project_id: number; cluster_id: number }
+>(
+  "GET",
+  ({ project_id, cluster_id }) =>
+    `/api/projects/${project_id}/clusters/${cluster_id}/agent/detect`
+);
+
+const installPorterAgent = baseApi<
+  {},
+  { project_id: number; cluster_id: number }
+>(
+  "POST",
+  ({ cluster_id, project_id }) =>
+    `/api/projects/${project_id}/clusters/${cluster_id}/agent/install`
+);
+
+const getKubeEvents = baseApi<
+  {
+    skip: number;
+    resource_type: string;
+    owner_type?: string;
+    owner_name?: string;
+  },
+  { project_id: number; cluster_id: number }
+>("GET", ({ project_id, cluster_id }) => {
+  return `/api/projects/${project_id}/clusters/${cluster_id}/kube_events`;
+});
+
+const getKubeEvent = baseApi<
+  {},
+  { project_id: number; cluster_id: number; kube_event_id: number }
+>(
+  "GET",
+  ({ project_id, cluster_id, kube_event_id }) =>
+    `/api/projects/${project_id}/clusters/${cluster_id}/kube_events/${kube_event_id}`
+);
+
+const getLogBuckets = baseApi<
+  {},
+  { project_id: number; cluster_id: number; kube_event_id: number }
+>(
+  "GET",
+  ({ project_id, cluster_id, kube_event_id }) =>
+    `/api/projects/${project_id}/clusters/${cluster_id}/kube_events/${kube_event_id}/log_buckets`
+);
+
+const getLogBucketLogs = baseApi<
+  { timestamp: number },
+  { project_id: number; cluster_id: number; kube_event_id: number }
+>(
+  "GET",
+  ({ project_id, cluster_id, kube_event_id }) =>
+    `/api/projects/${project_id}/clusters/${cluster_id}/kube_events/${kube_event_id}/logs`
+);
+
 // Bundle export to allow default api import (api.<method> is more readable)
 // Bundle export to allow default api import (api.<method> is more readable)
 export default {
 export default {
   checkAuth,
   checkAuth,
@@ -1197,6 +1269,7 @@ export default {
   getJobs,
   getJobs,
   getJobStatus,
   getJobStatus,
   getJobPods,
   getJobPods,
+  getPodByName,
   getMatchingPods,
   getMatchingPods,
   getMetrics,
   getMetrics,
   getNamespaces,
   getNamespaces,
@@ -1249,4 +1322,10 @@ export default {
   saveOnboardingState,
   saveOnboardingState,
   getOnboardingInfra,
   getOnboardingInfra,
   getOnboardingRegistry,
   getOnboardingRegistry,
+  detectPorterAgent,
+  installPorterAgent,
+  getKubeEvents,
+  getKubeEvent,
+  getLogBuckets,
+  getLogBucketLogs,
 };
 };

+ 16 - 0
dashboard/src/shared/types.tsx

@@ -331,3 +331,19 @@ export interface UsageData {
   exceeds: boolean;
   exceeds: boolean;
   exceeded_since?: string;
   exceeded_since?: string;
 }
 }
+
+export type KubeEvent = {
+  cluster_id: number;
+  event_type: string;
+  id: number;
+  message: string;
+  name: string;
+  namespace: string;
+  owner_name: string;
+  owner_type: string;
+  project_id: number;
+  reason: string;
+  resource_type: string;
+  timestamp: string;
+  sub_events: any[];
+};

+ 7 - 7
docker-compose.dev.yaml

@@ -45,13 +45,13 @@ services:
       - 6379:6379
       - 6379:6379
     volumes:
     volumes:
       - database:/var/lib/postgresql/data
       - database:/var/lib/postgresql/data
-  chartmuseum:
-    image: docker.io/bitnami/chartmuseum:0-debian-10
-    container_name: chartmuseum
-    ports:
-      - 5000:8080
-    volumes:
-      - chartmuseum:/bitnami/data
+  # chartmuseum:
+  #   image: docker.io/bitnami/chartmuseum:0-debian-10
+  #   container_name: chartmuseum
+  #   ports:
+  #     - 5000:8080
+  #   volumes:
+  #     - chartmuseum:/bitnami/data
   nginx:
   nginx:
     image: nginx:mainline-alpine
     image: nginx:mainline-alpine
     container_name: nginx
     container_name: nginx

+ 4 - 2
docker/Dockerfile

@@ -37,12 +37,14 @@ RUN --mount=type=cache,target=/root/.cache/go-build \
 
 
 # Webpack build environment
 # Webpack build environment
 # -------------------------
 # -------------------------
-FROM node:lts as build-webpack
+FROM node:16 as build-webpack
 WORKDIR /webpack
 WORKDIR /webpack
 
 
 COPY ./dashboard ./
 COPY ./dashboard ./
 
 
-RUN npm i
+RUN npm install -g npm@8.1
+
+RUN npm i --legacy-peer-deps
 
 
 ENV NODE_ENV=production
 ENV NODE_ENV=production
 
 

+ 11 - 0
docs/developing/setup-gh-app-locally.md

@@ -0,0 +1,11 @@
+# Setup GitHub App Locally
+
+1. Follow the instructions from https://docs.porter.run/docs/github#setting-up-github-repository-integrations (Only from the `Setting Up Github Repository Integrations` section)
+2. Download the `ngrok` CLI tool from https://ngrok.com
+3. Expose the local Porter instance by calling `ngrok http 8080` from a shell (you might have to login with an `ngrok` account). This is going to emit a `*.ngrok.io` subdomain something like
+```
+Forwarding http://a7af-103-98-78-24.ngrok.io -> http://localhost:8080
+```
+4. Open the GitHub app settings by going to https://github.com/settings/apps and select the app you created in step 1 by clicking the `Edit` button.
+5. Go to the `Webhook` section and update the `Webhook URL` to `<generated ngrok domain in step 3>/api/integrations/github-app/webhook`. So in the case of the above example, it will be `http://a7af-103-98-78-24.ngrok.io/api/integrations/github-app/webhook`
+6. Open your local Porter instance by going to `http://localhost:8081` and you should now be able to now install your own GitHub app!

+ 5 - 1
docs/guides/https-and-custom-domains.md

@@ -140,4 +140,8 @@ It might take a few minutes for the HTTPS Issuer instance to be ready. To be saf
 
 
 After you hit deploy, it might take a few minutes for the endpoint to be secured with HTTPS. Once that's done, you will be able to access endpoints on the domain you have specified. 
 After you hit deploy, it might take a few minutes for the endpoint to be secured with HTTPS. Once that's done, you will be able to access endpoints on the domain you have specified. 
 
 
-With wildcard domain enabled, you can create deployments and expose them on domains without having to create another DNS record, as long as the domain matches the wildcard domain.
+With wildcard domain enabled, you can create deployments and expose them on domains without having to create another DNS record, as long as the domain matches the wildcard domain.
+
+# Choosing between `A` and `CNAME` records
+
+A basic rule of thumb you can follow whilst trying to choose between setting up an `A` records as opposed to a `CNAME` record for your cluster, is to see how your cluster's load balancer is exposed to the Internet. If your load balancer exposes a public IP, you should use an `A` record for your custom domain that points to the public IP - as is the case with GKE. If your load balancer exposes a FQDN, then you should use a `CNAME` record - this is common with EKS clusters that use AWS Network Load Balancers/Application Load Balancers.

+ 4 - 2
ee/docker/ee.Dockerfile

@@ -38,12 +38,14 @@ RUN --mount=type=cache,target=/root/.cache/go-build \
 
 
 # Webpack build environment
 # Webpack build environment
 # -------------------------
 # -------------------------
-FROM node:lts as build-webpack
+FROM node:16 as build-webpack
 WORKDIR /webpack
 WORKDIR /webpack
 
 
 COPY ./dashboard ./
 COPY ./dashboard ./
 
 
-RUN npm i
+RUN npm install -g npm@8.1
+
+RUN npm i --legacy-peer-deps
 
 
 ENV NODE_ENV=production
 ENV NODE_ENV=production
 
 

+ 6 - 0
ee/integrations/httpbackend/backend.go

@@ -46,6 +46,8 @@ func (c *Client) GetDesiredState(name string) (*DesiredTFState, error) {
 	return resp.Data, nil
 	return resp.Data, nil
 }
 }
 
 
+var ErrNotFound = fmt.Errorf("Not found")
+
 func (c *Client) getRequest(path string, dst interface{}) error {
 func (c *Client) getRequest(path string, dst interface{}) error {
 	req, err := http.NewRequest(
 	req, err := http.NewRequest(
 		"GET",
 		"GET",
@@ -75,6 +77,10 @@ func (c *Client) getRequest(path string, dst interface{}) error {
 			return fmt.Errorf("request failed with status code %d, but could not read body (%s)\n", res.StatusCode, err.Error())
 			return fmt.Errorf("request failed with status code %d, but could not read body (%s)\n", res.StatusCode, err.Error())
 		}
 		}
 
 
+		if res.StatusCode == http.StatusNotFound {
+			return ErrNotFound
+		}
+
 		return fmt.Errorf("request failed with status code %d: %s\n", res.StatusCode, string(resBytes))
 		return fmt.Errorf("request failed with status code %d: %s\n", res.StatusCode, string(resBytes))
 	}
 	}
 
 

+ 12 - 19
go.mod

@@ -3,10 +3,9 @@ module github.com/porter-dev/porter
 go 1.16
 go 1.16
 
 
 require (
 require (
-	cloud.google.com/go v0.65.0
+	cloud.google.com/go v0.81.0
 	github.com/AlecAivazis/survey/v2 v2.2.9
 	github.com/AlecAivazis/survey/v2 v2.2.9
-	github.com/DATA-DOG/go-sqlmock v1.5.0 // indirect
-	github.com/Masterminds/semver v1.5.0 // indirect
+	github.com/BurntSushi/toml v0.4.1 // indirect
 	github.com/Masterminds/semver/v3 v3.1.1
 	github.com/Masterminds/semver/v3 v3.1.1
 	github.com/aws/aws-sdk-go v1.35.4
 	github.com/aws/aws-sdk-go v1.35.4
 	github.com/bradleyfalzon/ghinstallation v1.1.1
 	github.com/bradleyfalzon/ghinstallation v1.1.1
@@ -19,11 +18,9 @@ require (
 	github.com/docker/docker v20.10.7+incompatible
 	github.com/docker/docker v20.10.7+incompatible
 	github.com/docker/docker-credential-helpers v0.6.3
 	github.com/docker/docker-credential-helpers v0.6.3
 	github.com/docker/go-connections v0.4.0
 	github.com/docker/go-connections v0.4.0
-	github.com/fatih/color v1.9.0
+	github.com/fatih/color v1.10.0
 	github.com/getsentry/sentry-go v0.11.0
 	github.com/getsentry/sentry-go v0.11.0
 	github.com/go-chi/chi v4.1.2+incompatible
 	github.com/go-chi/chi v4.1.2+incompatible
-	github.com/go-playground/locales v0.13.0
-	github.com/go-playground/universal-translator v0.17.0
 	github.com/go-playground/validator/v10 v10.3.0
 	github.com/go-playground/validator/v10 v10.3.0
 	github.com/go-redis/redis/v8 v8.11.0
 	github.com/go-redis/redis/v8 v8.11.0
 	github.com/go-test/deep v1.0.7
 	github.com/go-test/deep v1.0.7
@@ -38,37 +35,33 @@ require (
 	github.com/gorilla/websocket v1.4.2
 	github.com/gorilla/websocket v1.4.2
 	github.com/hashicorp/golang-lru v0.5.3 // indirect
 	github.com/hashicorp/golang-lru v0.5.3 // indirect
 	github.com/itchyny/gojq v0.12.1
 	github.com/itchyny/gojq v0.12.1
-	github.com/jinzhu/gorm v1.9.16 // indirect
 	github.com/joeshaw/envdecode v0.0.0-20200121155833-099f1fc765bd
 	github.com/joeshaw/envdecode v0.0.0-20200121155833-099f1fc765bd
-	github.com/josharian/impl v1.1.0 // indirect
 	github.com/kris-nova/logger v0.0.0-20181127235838-fd0d87064b06
 	github.com/kris-nova/logger v0.0.0-20181127235838-fd0d87064b06
 	github.com/kris-nova/lolgopher v0.0.0-20180921204813-313b3abb0d9b // indirect
 	github.com/kris-nova/lolgopher v0.0.0-20180921204813-313b3abb0d9b // indirect
-	github.com/mitchellh/mapstructure v1.3.1 // indirect
+	github.com/mattn/go-runewidth v0.0.12 // indirect
 	github.com/moby/moby v20.10.6+incompatible
 	github.com/moby/moby v20.10.6+incompatible
 	github.com/moby/term v0.0.0-20201216013528-df9cb8a40635
 	github.com/moby/term v0.0.0-20201216013528-df9cb8a40635
+	github.com/onsi/gomega v1.16.0 // indirect
 	github.com/opencontainers/image-spec v1.0.1
 	github.com/opencontainers/image-spec v1.0.1
+	github.com/pelletier/go-toml v1.9.4
 	github.com/pkg/errors v0.9.1
 	github.com/pkg/errors v0.9.1
 	github.com/rogpeppe/go-internal v1.5.2 // indirect
 	github.com/rogpeppe/go-internal v1.5.2 // indirect
 	github.com/rs/zerolog v1.20.0
 	github.com/rs/zerolog v1.20.0
 	github.com/segmentio/backo-go v0.0.0-20200129164019-23eae7c10bd3 // indirect
 	github.com/segmentio/backo-go v0.0.0-20200129164019-23eae7c10bd3 // indirect
 	github.com/sendgrid/rest v2.6.3+incompatible // indirect
 	github.com/sendgrid/rest v2.6.3+incompatible // indirect
 	github.com/sendgrid/sendgrid-go v3.8.0+incompatible
 	github.com/sendgrid/sendgrid-go v3.8.0+incompatible
-	github.com/spf13/cobra v1.1.3
-	github.com/spf13/jwalterweatherman v1.1.0 // indirect
+	github.com/spf13/cobra v1.2.1
 	github.com/spf13/pflag v1.0.5
 	github.com/spf13/pflag v1.0.5
-	github.com/spf13/viper v1.7.0
+	github.com/spf13/viper v1.8.1
 	github.com/stretchr/testify v1.7.0
 	github.com/stretchr/testify v1.7.0
 	github.com/xtgo/uuid v0.0.0-20140804021211-a0b114877d4c // indirect
 	github.com/xtgo/uuid v0.0.0-20140804021211-a0b114877d4c // indirect
 	golang.org/x/crypto v0.0.0-20210322153248-0c34fe9e7dc2
 	golang.org/x/crypto v0.0.0-20210322153248-0c34fe9e7dc2
 	golang.org/x/mod v0.5.0 // indirect
 	golang.org/x/mod v0.5.0 // indirect
-	golang.org/x/oauth2 v0.0.0-20200902213428-5d25da1a8d43
-	golang.org/x/sync v0.0.0-20210220032951-036812b2e83c // indirect
+	golang.org/x/net v0.0.0-20210614182718-04defd469f4e // indirect
+	golang.org/x/oauth2 v0.0.0-20210402161424-2e8d93401602
 	golang.org/x/sys v0.0.0-20210819135213-f52c844e1c1c // indirect
 	golang.org/x/sys v0.0.0-20210819135213-f52c844e1c1c // indirect
-	golang.org/x/tools v0.1.5 // indirect
-	google.golang.org/api v0.30.0
-	google.golang.org/genproto v0.0.0-20201110150050-8816d57aaa9a
-	google.golang.org/grpc v1.38.0 // indirect
-	gopkg.in/ini.v1 v1.56.0 // indirect
+	google.golang.org/api v0.44.0
+	google.golang.org/genproto v0.0.0-20210602131652-f16073e35f0c
 	gopkg.in/segmentio/analytics-go.v3 v3.1.0
 	gopkg.in/segmentio/analytics-go.v3 v3.1.0
 	gopkg.in/yaml.v2 v2.4.0
 	gopkg.in/yaml.v2 v2.4.0
 	gorm.io/driver/postgres v1.0.2
 	gorm.io/driver/postgres v1.0.2

Неке датотеке нису приказане због велике количине промена