ソースを参照

Merge branch 'nafees' into nico/por-211-provide-render-style-options-upon

jnfrati 4 年 前
コミット
b528bbcbad
100 ファイル変更19259 行追加2425 行削除
  1. 3 0
      .gitignore
  2. 2 2
      api/server/authn/handler.go
  3. 8 6
      api/server/authn/session_helpers.go
  4. 5 1
      api/server/authz/git_installation.go
  5. 51 0
      api/server/handlers/cluster/detect_agent_installed.go
  6. 111 0
      api/server/handlers/cluster/install_agent.go
  7. 27 10
      api/server/handlers/gitinstallation/get_buildpack.go
  8. 5 1
      api/server/handlers/handler.go
  9. 9 1
      api/server/handlers/infra/get_current.go
  10. 9 1
      api/server/handlers/infra/get_desired.go
  11. 213 0
      api/server/handlers/kube_events/create.go
  12. 46 0
      api/server/handlers/kube_events/get.go
  13. 96 0
      api/server/handlers/kube_events/get_log_buckets.go
  14. 97 0
      api/server/handlers/kube_events/get_logs.go
  15. 60 0
      api/server/handlers/kube_events/list.go
  16. 63 0
      api/server/handlers/namespace/delete_crd.go
  17. 2 8
      api/server/handlers/release/create.go
  18. 63 1
      api/server/handlers/release/get.go
  19. 1 1
      api/server/handlers/release/get_steps.go
  20. 129 0
      api/server/handlers/release/stream_form.go
  21. 6 5
      api/server/handlers/release/ugprade.go
  22. 4 8
      api/server/handlers/release/update_build_config.go
  23. 3 3
      api/server/handlers/release/update_steps.go
  24. 5 4
      api/server/handlers/release/upgrade_webhook.go
  25. 13 2
      api/server/handlers/user/create.go
  26. 11 11
      api/server/handlers/user/github_callback.go
  27. 11 11
      api/server/handlers/user/google_callback.go
  28. 8 1
      api/server/handlers/user/login.go
  29. 200 0
      api/server/router/cluster.go
  30. 29 0
      api/server/router/namespace.go
  31. 32 0
      api/server/router/release.go
  32. 9 12
      api/types/build_config.go
  33. 16 0
      api/types/crd.go
  34. 91 0
      api/types/kube_events.go
  35. 2 0
      api/types/release.go
  36. 6 0
      cli/cmd/pack/pack.go
  37. 14115 695
      dashboard/package-lock.json
  38. 2 1
      dashboard/package.json
  39. 157 0
      dashboard/src/components/Dropdown.tsx
  40. 94 44
      dashboard/src/components/ExpandableResource.tsx
  41. 14 13
      dashboard/src/components/ProvisionerStatus.tsx
  42. 1 0
      dashboard/src/components/ResourceTab.tsx
  43. 1 1
      dashboard/src/components/SaveButton.tsx
  44. 52 5
      dashboard/src/components/Selector.tsx
  45. 1 1
      dashboard/src/components/UnexpectedErrorPage.tsx
  46. 202 0
      dashboard/src/components/events/EventCard.tsx
  47. 301 0
      dashboard/src/components/events/SubEventsList.tsx
  48. 153 0
      dashboard/src/components/events/sub-events/LogBucketCard.tsx
  49. 57 0
      dashboard/src/components/events/sub-events/SubEventCard.tsx
  50. 214 0
      dashboard/src/components/events/useEvents.ts
  51. 3 5
      dashboard/src/components/image-selector/ImageList.tsx
  52. 3 5
      dashboard/src/components/image-selector/TagList.tsx
  53. 83 3
      dashboard/src/components/porter-form/field-components/ResourceList.tsx
  54. 12 0
      dashboard/src/components/porter-form/types.ts
  55. 1 1
      dashboard/src/main/home/Home.tsx
  56. 12 0
      dashboard/src/main/home/ModalHandler.tsx
  57. 5 1
      dashboard/src/main/home/cluster-dashboard/dashboard/Dashboard.tsx
  58. 214 0
      dashboard/src/main/home/cluster-dashboard/dashboard/events/EventsTab.tsx
  59. 7 5
      dashboard/src/main/home/cluster-dashboard/env-groups/EnvGroupDashboard.tsx
  60. 115 99
      dashboard/src/main/home/cluster-dashboard/expanded-chart/ExpandedChart.tsx
  61. 0 120
      dashboard/src/main/home/cluster-dashboard/expanded-chart/events/EventCard.tsx
  62. 0 94
      dashboard/src/main/home/cluster-dashboard/expanded-chart/events/EventDetail.tsx
  63. 189 159
      dashboard/src/main/home/cluster-dashboard/expanded-chart/events/EventsTab.tsx
  64. 1 0
      dashboard/src/main/home/cluster-dashboard/expanded-chart/status/Logs.tsx
  65. 107 1
      dashboard/src/main/home/cluster-dashboard/expanded-chart/status/StatusSection.tsx
  66. 3 3
      dashboard/src/main/home/dashboard/Dashboard.tsx
  67. 4 5
      dashboard/src/main/home/modals/DeleteNamespaceModal.tsx
  68. 58 0
      dashboard/src/main/home/modals/SkipProvisioningModal.tsx
  69. 60 48
      dashboard/src/main/home/navbar/Help.tsx
  70. 11 11
      dashboard/src/main/home/navbar/Navbar.tsx
  71. 32 0
      dashboard/src/main/home/onboarding/Onboarding.tsx
  72. 19 7
      dashboard/src/main/home/onboarding/components/ProviderSelector.tsx
  73. 15 0
      dashboard/src/main/home/onboarding/state/StateHandler.ts
  74. 6 2
      dashboard/src/main/home/onboarding/state/StepHandler.ts
  75. 106 4
      dashboard/src/main/home/onboarding/steps/ConnectRegistry/ConnectRegistry.tsx
  76. 193 0
      dashboard/src/main/home/onboarding/steps/ConnectRegistry/components/Registry.tsx
  77. 1 1
      dashboard/src/main/home/onboarding/steps/ConnectSource.tsx
  78. 6 3
      dashboard/src/main/home/onboarding/steps/ProvisionResources/ProvisionResources.tsx
  79. 1 1
      dashboard/src/main/home/onboarding/steps/ProvisionResources/forms/FormFlow.tsx
  80. 0 365
      dashboard/src/main/home/onboarding/steps/ProvisionResources/forms/SharedStatus.tsx
  81. 641 0
      dashboard/src/main/home/onboarding/steps/ProvisionResources/forms/StatusPage.tsx
  82. 0 1
      dashboard/src/main/home/onboarding/steps/ProvisionResources/forms/_AWSProvisionerForm.tsx
  83. 0 6
      dashboard/src/main/home/onboarding/steps/ProvisionResources/forms/_DOProvisionerForm.tsx
  84. 1 3
      dashboard/src/main/home/onboarding/steps/ProvisionResources/forms/_GCPProvisionerForm.tsx
  85. 2 1
      dashboard/src/main/home/sidebar/Sidebar.tsx
  86. 63 0
      dashboard/src/shared/api.tsx
  87. 16 0
      dashboard/src/shared/types.tsx
  88. 4 2
      docker/Dockerfile
  89. 4 2
      ee/docker/ee.Dockerfile
  90. 6 0
      ee/integrations/httpbackend/backend.go
  91. 20 26
      internal/integrations/buildpacks/go.go
  92. 68 169
      internal/integrations/buildpacks/nodejs.go
  93. 19 150
      internal/integrations/buildpacks/python.go
  94. 26 208
      internal/integrations/buildpacks/ruby.go
  95. 15 30
      internal/integrations/buildpacks/shared.go
  96. 65 30
      internal/integrations/slack/notifier.go
  97. 25 0
      internal/kubernetes/agent.go
  98. 124 0
      internal/kubernetes/porter_agent/logs.go
  99. 6 5
      internal/models/build_config.go
  100. 77 0
      internal/models/kube_events.go

+ 3 - 0
.gitignore

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

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

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

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

@@ -12,25 +12,27 @@ func SaveUserAuthenticated(
 	r *http.Request,
 	config *config.Config,
 	user *models.User,
-) error {
+) (string, error) {
 	session, err := config.Store.Get(r, config.ServerConf.CookieName)
 
 	if err != nil {
-		return err
+		return "", err
 	}
 
 	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["user_id"] = user.ID
 	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(

+ 5 - 1
api/server/authz/git_installation.go

@@ -75,8 +75,12 @@ func (p *GitInstallationScopedMiddleware) doesUserHaveGitInstallationAccess(gith
 		return err
 	}
 
+	if p.config.GithubAppConf == nil {
+		return fmt.Errorf("config has invalid GithubAppConf")
+	}
+
 	if _, _, err = oauth.GetAccessToken(oauthInt.SharedOAuthModel,
-		p.config.GithubConf,
+		&p.config.GithubAppConf.Config,
 		oauth.MakeUpdateGithubAppOauthIntegrationFunction(oauthInt, p.config.Repo)); err != nil {
 		return err
 	}

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

+ 27 - 10
api/server/handlers/gitinstallation/get_buildpack.go

@@ -15,6 +15,26 @@ import (
 	"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",
+			"paketobuildpacks/builder:tiny",
+			"paketobuildpacks/builder:base",
+		},
+	}
+	builders[buildpacks.HerokuBuilder] = &buildpacks.BuilderInfo{
+		Name: "Heroku",
+		Builders: []string{
+			"heroku/buildpacks:20",
+			"heroku/buildpacks:18",
+		},
+	}
+	return builders
+}
+
 type GithubGetBuildpackHandler struct {
 	handlers.PorterHandlerReadWriter
 	authz.KubernetesAgentGetter
@@ -73,26 +93,23 @@ func (c *GithubGetBuildpackHandler) ServeHTTP(w http.ResponseWriter, r *http.Req
 		return
 	}
 
+	builderInfoMap := initBuilderInfo()
 	var wg sync.WaitGroup
 	wg.Add(len(buildpacks.Runtimes))
-	detectResults := make(chan *buildpacks.RuntimeResponse, len(buildpacks.Runtimes))
 	for i := range buildpacks.Runtimes {
 		go func(idx int) {
-			detectResults <- buildpacks.Runtimes[idx].Detect(
+			buildpacks.Runtimes[idx].Detect(
 				client, directoryContents, owner, name, request.Dir, repoContentOptions,
+				builderInfoMap[buildpacks.PaketoBuilder], builderInfoMap[buildpacks.HerokuBuilder],
 			)
 			wg.Done()
 		}(i)
 	}
 	wg.Wait()
-	close(detectResults)
 
-	var matches []*buildpacks.RuntimeResponse
-	for detectRes := range detectResults {
-		if detectRes != nil {
-			matches = append(matches, detectRes)
-		}
+	var builders []*buildpacks.BuilderInfo
+	for _, v := range builderInfoMap {
+		builders = append(builders, v)
 	}
-
-	c.WriteResult(w, r, matches)
+	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
 	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 {
 		project, _ := r.Context().Value(types.ProjectScope).(*models.Project)

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

@@ -1,6 +1,7 @@
 package infra
 
 import (
+	"errors"
 	"net/http"
 
 	"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
 	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))
 		return
 	}

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

@@ -1,6 +1,7 @@
 package infra
 
 import (
+	"errors"
 	"net/http"
 
 	"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
 	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))
 		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)
+}

+ 63 - 0
api/server/handlers/namespace/delete_crd.go

@@ -0,0 +1,63 @@
+package namespace
+
+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"
+	"github.com/porter-dev/porter/internal/templater/dynamic"
+)
+
+type CRDDeleteHandler struct {
+	handlers.PorterHandlerReadWriter
+	authz.KubernetesAgentGetter
+}
+
+func NewCRDDeleteHandler(
+	config *config.Config,
+	decoderValidator shared.RequestDecoderValidator,
+	writer shared.ResultWriter,
+) *CRDDeleteHandler {
+	return &CRDDeleteHandler{
+		PorterHandlerReadWriter: handlers.NewDefaultPorterHandler(config, decoderValidator, writer),
+		KubernetesAgentGetter:   authz.NewOutOfClusterAgentGetter(config),
+	}
+}
+
+func (c *CRDDeleteHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	cluster, _ := r.Context().Value(types.ClusterScope).(*models.Cluster)
+	client, err := c.GetDynamicClient(r, cluster)
+
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	request := &types.DeleteCRDRequest{}
+
+	if ok := c.DecodeAndValidate(w, r, request); !ok {
+		return
+	}
+
+	crdWriter := dynamic.NewDynamicTemplateWriter(client, &dynamic.Object{
+		Group:     request.Group,
+		Version:   request.Version,
+		Resource:  request.Resource,
+		Namespace: request.Namespace,
+		Name:      request.Name,
+	}, map[string]interface{}{})
+
+	err = crdWriter.Delete()
+
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	w.WriteHeader(http.StatusOK)
+}

+ 2 - 8
api/server/handlers/release/create.go

@@ -324,16 +324,10 @@ func createBuildConfig(
 		return nil, err
 	}
 
-	buildpacks, err := json.Marshal(bcRequest.Buildpacks)
-	if err != nil {
-		return nil, err
-	}
-
 	// handle write to the database
 	bc, err := config.Repo.BuildConfig().CreateBuildConfig(&models.BuildConfig{
-		Name:       bcRequest.Name,
-		Runtime:    bcRequest.Runtime,
-		Buildpacks: buildpacks,
+		Builder:    bcRequest.Builder,
+		Buildpacks: strings.Join(bcRequest.Buildpacks, ","),
 		Config:     data,
 	})
 	if err != nil {

+ 63 - 1
api/server/handlers/release/get.go

@@ -104,10 +104,72 @@ func (c *ReleaseGetHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
 	form, err := parser.GetFormFromRelease(parserDef, helmRelease)
 
 	if err != nil {
-		// TODO: log non-fatal parsing error
+		c.HandleAPIErrorNoWrite(w, r, apierrors.NewErrInternal(err))
 	} else {
 		res.Form = form
 	}
+	// if form not populated, detect common charts
+	if res.Form == nil {
+		// for now just case by name
+		if res.Release.Chart.Name() == "cert-manager" {
+			formYAML, err := parser.FormYAMLFromBytes(parserDef, []byte(certManagerForm), "")
+
+			if err == nil {
+				res.Form = formYAML
+			}
+		}
+	}
 
 	c.WriteResult(w, r, res)
 }
+
+const certManagerForm string = `tags:
+- hello
+tabs:
+- name: main
+  context:
+    type: cluster
+    config:
+      group: cert-manager.io
+      version: v1
+      resource: certificates
+  label: Certificates
+  sections:
+  - name: section_one
+    contents: 
+    - type: heading
+      label: Certificates
+    - type: resource-list
+      settings:
+        options:
+          resource-button:
+            name: "Renew Certificate"
+            description: "This will delete the existing certificate resource, triggering a new certificate request."
+            actions:
+            - delete:
+                scope: namespace
+                relative_uri: /crd
+                context:
+                  type: cluster
+                  config:
+                    group: cert-manager.io
+                    version: v1
+                    resource: certificates
+      value: |
+        .items[] | { 
+          metadata: .metadata,
+          name: "\(.spec.dnsNames | join(","))", 
+          label: "\(.metadata.namespace)/\(.metadata.name)",
+          status: (
+            ([.status.conditions[].type] | index("Ready")) as $index | (
+              if $index then (
+                if .status.conditions[$index].status == "True" then "Ready" else "Not Ready" end
+              ) else (
+                "Not Ready"
+              ) end
+            )
+          ),
+          timestamp: .status.conditions[0].lastTransitionTime,
+          message: [.status.conditions[].message] | unique | join(","),
+          data: {}
+        }`

+ 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)
 
 	if release.EventContainer != 0 {
-		subevents, err := c.Repo().Event().ReadEventsByContainerID(release.EventContainer)
+		subevents, err := c.Repo().BuildEvent().ReadEventsByContainerID(release.EventContainer)
 
 		if err != nil {
 			c.HandleAPIError(w, r, apierrors.NewErrInternal(err))

+ 129 - 0
api/server/handlers/release/stream_form.go

@@ -0,0 +1,129 @@
+package release
+
+import (
+	"encoding/json"
+	"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/websocket"
+	"github.com/porter-dev/porter/api/types"
+	"github.com/porter-dev/porter/internal/models"
+	"github.com/porter-dev/porter/internal/templater/parser"
+	"helm.sh/helm/v3/pkg/release"
+)
+
+type StreamFormHandler struct {
+	handlers.PorterHandlerReadWriter
+	authz.KubernetesAgentGetter
+}
+
+func NewStreamFormHandler(
+	config *config.Config,
+	decoderValidator shared.RequestDecoderValidator,
+	writer shared.ResultWriter,
+) *StreamFormHandler {
+	return &StreamFormHandler{
+		PorterHandlerReadWriter: handlers.NewDefaultPorterHandler(config, decoderValidator, writer),
+		KubernetesAgentGetter:   authz.NewOutOfClusterAgentGetter(config),
+	}
+}
+
+func getStreamWriter(rw *websocket.WebsocketSafeReadWriter) func(val map[string]interface{}) error {
+	return func(val map[string]interface{}) error {
+		// parse value into json
+		bytes, err := json.Marshal(val)
+
+		if err != nil {
+			return err
+		}
+
+		_, err = rw.Write(bytes)
+		return err
+	}
+}
+
+func (c *StreamFormHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	helmRelease, _ := r.Context().Value(types.ReleaseScope).(*release.Release)
+	cluster, _ := r.Context().Value(types.ClusterScope).(*models.Cluster)
+	safeRW := r.Context().Value(types.RequestCtxWebsocketKey).(*websocket.WebsocketSafeReadWriter)
+
+	request := &types.StreamCRDRequest{}
+
+	if ok := c.DecodeAndValidate(w, r, request); !ok {
+		return
+	}
+
+	// look for the form using the dynamic client
+	dynClient, err := c.GetDynamicClient(r, cluster)
+
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	parserDef := &parser.ClientConfigDefault{
+		DynamicClient: dynClient,
+		HelmChart:     helmRelease.Chart,
+		HelmRelease:   helmRelease,
+	}
+
+	var formData []byte
+
+	for _, file := range helmRelease.Chart.Files {
+		if strings.Contains(file.Name, "form.yaml") {
+			formData = file.Data
+			break
+		}
+	}
+
+	// if form data isn't found, look for common charts
+	if formData == nil {
+		// for now just case by name
+		if helmRelease.Chart.Name() == "cert-manager" {
+			formData = []byte(certManagerForm)
+		}
+	}
+
+	stopper := make(chan struct{})
+	errorchan := make(chan error)
+	defer close(stopper)
+
+	go func() {
+		// listens for websocket closing handshake
+		for {
+			if _, _, err := safeRW.ReadMessage(); err != nil {
+				errorchan <- nil
+				return
+			}
+		}
+	}()
+
+	onData := getStreamWriter(safeRW)
+
+	err = parser.FormStreamer(parserDef, formData, "", &types.FormContext{
+		Type: "cluster",
+		Config: map[string]string{
+			"group":    request.Group,
+			"resource": request.Resource,
+			"version":  request.Version,
+		},
+	}, onData, stopper)
+
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	for {
+		select {
+		case err := <-errorchan:
+			c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+			return
+		}
+	}
+}

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

+ 4 - 8
api/server/handlers/release/update_build_config.go

@@ -3,6 +3,7 @@ package release
 import (
 	"encoding/json"
 	"net/http"
+	"strings"
 
 	"github.com/porter-dev/porter/api/server/handlers"
 	"github.com/porter-dev/porter/api/server/shared"
@@ -56,19 +57,14 @@ func (c *UpdateBuildConfigHandler) ServeHTTP(w http.ResponseWriter, r *http.Requ
 		return
 	}
 
-	buildpacks, err := json.Marshal(request.Buildpacks)
-	if err != nil {
-		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
-		return
-	}
-
 	buildConfig := &models.BuildConfig{
-		Buildpacks: buildpacks,
+		Builder:    request.Builder,
+		Buildpacks: strings.Join(request.Buildpacks, ","),
 		Config:     config,
 	}
 
 	buildConfig.ID = release.BuildConfig
-	buildConfig, err = c.Repo().BuildConfig().UpdateBuildConfig(buildConfig)
+	_, err = c.Repo().BuildConfig().UpdateBuildConfig(buildConfig)
 
 	if err != nil {
 		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))

+ 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 {
 		// 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 {
 			c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
 			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 {
 		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
 		return
 	}
 
-	if err := c.Repo().Event().AppendEvent(container, &models.SubEvent{
+	if err := c.Repo().BuildEvent().AppendEvent(container, &models.SubEvent{
 		EventContainerID: container.ID,
 		EventID:          request.Event.EventID,
 		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,
 		Namespace:   rel.Namespace,
 		URL: fmt.Sprintf(
-			"%s/applications/%s/%s/%s",
+			"%s/applications/%s/%s/%s?project_id=%d",
 			c.Config().ServerConf.ServerURL,
 			url.PathEscape(cluster.Name),
 			release.Namespace,
 			rel.Name,
-		) + fmt.Sprintf("?project_id=%d", release.ProjectID),
+			cluster.ProjectID,
+		),
 	}
 
 	rel, err = helmAgent.UpgradeReleaseByValues(conf, c.Config().DOConf)
 
 	if err != nil {
-		notifyOpts.Status = slack.StatusFailed
+		notifyOpts.Status = slack.StatusHelmFailed
 		notifyOpts.Info = err.Error()
 
 		notifier.Notify(notifyOpts)
@@ -181,7 +182,7 @@ func (c *WebhookHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
 		return
 	}
 
-	notifyOpts.Status = string(rel.Info.Status)
+	notifyOpts.Status = slack.StatusHelmDeployed
 	notifyOpts.Version = rel.Version
 
 	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
-	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))
 		return
 	}
 
 	// non-fatal send email verification
 	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))
@@ -94,6 +100,11 @@ func (u *UserCreateHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
 		Email:               user.Email,
 	}))
 
+	if redirect != "" {
+		http.Redirect(w, r, redirect, http.StatusFound)
+		return
+	}
+
 	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))
 
 	// 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))
 		return
 	}
 
 	// non-fatal send email verification
 	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 {
-			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) {

+ 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))
 
 	// 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))
 		return
 	}
 
 	// non-fatal send email verification
 	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 {
-			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) {

+ 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
-	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))
 		return
 	}
 
+	if redirect != "" {
+		http.Redirect(w, r, redirect, http.StatusFound)
+		return
+	}
+
 	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/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/config"
 	"github.com/porter-dev/porter/api/types"
@@ -451,6 +452,205 @@ func getClusterRoutes(
 		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
 	listNGINXIngressesEndpoint := factory.NewAPIEndpoint(
 		&types.APIRequestMetadata{

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

@@ -234,6 +234,35 @@ func getNamespaceRoutes(
 		Router:   r,
 	})
 
+	// DELETE /api/projects/{project_id}/clusters/{cluster_id}/namespaces/{namespace}/crd -> namespace.NewCRDDeleteHandler
+	deleteCRDEndpoint := factory.NewAPIEndpoint(
+		&types.APIRequestMetadata{
+			Verb:   types.APIVerbDelete,
+			Method: types.HTTPVerbDelete,
+			Path: &types.Path{
+				Parent:       basePath,
+				RelativePath: relPath + "/crd",
+			},
+			Scopes: []types.PermissionScope{
+				types.UserScope,
+				types.ProjectScope,
+				types.ClusterScope,
+			},
+		},
+	)
+
+	deleteCRDHandler := namespace.NewCRDDeleteHandler(
+		config,
+		factory.GetDecoderValidator(),
+		factory.GetResultWriter(),
+	)
+
+	routes = append(routes, &Route{
+		Endpoint: deleteCRDEndpoint,
+		Handler:  deleteCRDHandler,
+		Router:   r,
+	})
+
 	// GET /api/projects/{project_id}/clusters/{cluster_id}/namespaces/{namespace}/releases -> namespace.NewListReleasesHandler
 	listReleasesEndpoint := factory.NewAPIEndpoint(
 		&types.APIRequestMetadata{

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

@@ -82,6 +82,38 @@ func getReleaseRoutes(
 		Router:   r,
 	})
 
+	// GET /api/projects/{project_id}/clusters/{cluster_id}/namespaces/{namespace}/releases/{name}/{version}/form_stream -> release.NewStreamFormHandler
+	streamFormEndpoint := factory.NewAPIEndpoint(
+		&types.APIRequestMetadata{
+			Verb:   types.APIVerbGet,
+			Method: types.HTTPVerbGet,
+			Path: &types.Path{
+				Parent:       basePath,
+				RelativePath: relPath + "/form_stream",
+			},
+			Scopes: []types.PermissionScope{
+				types.UserScope,
+				types.ProjectScope,
+				types.ClusterScope,
+				types.NamespaceScope,
+				types.ReleaseScope,
+			},
+			IsWebsocket: true,
+		},
+	)
+
+	streamFormHandler := release.NewStreamFormHandler(
+		config,
+		factory.GetDecoderValidator(),
+		factory.GetResultWriter(),
+	)
+
+	routes = append(routes, &Route{
+		Endpoint: streamFormEndpoint,
+		Handler:  streamFormHandler,
+		Router:   r,
+	})
+
 	// GET /api/projects/{project_id}/clusters/{cluster_id}/namespaces/{namespace}/releases/{name}/{version}/controllers -> release.NewGetControllersHandler
 	getControllersEndpoint := factory.NewAPIEndpoint(
 		&types.APIRequestMetadata{

+ 9 - 12
api/types/build_config.go

@@ -1,23 +1,20 @@
 package types
 
-import "github.com/porter-dev/porter/internal/integrations/buildpacks"
-
 // BuildConfig
 type BuildConfig struct {
-	Name       string `json:"name"`
-	Runtime    string `json:"runtime"`
-	Buildpacks []byte `json:"buildpacks"` // FIXME: should be a []string
-	Config     []byte `json:"data"`
+	Builder    string   `json:"builder"`
+	Buildpacks []string `json:"buildpacks"`
+	Config     []byte   `json:"data"`
 }
 
 type CreateBuildConfigRequest struct {
-	Name       string                      `json:"name" form:"required"`
-	Runtime    string                      `json:"runtime" form:"required"`
-	Buildpacks []*buildpacks.BuildpackInfo `json:"buildpacks"`
-	Config     map[string]interface{}      `json:"config,omitempty"`
+	Builder    string                 `json:"builder" form:"required"`
+	Buildpacks []string               `json:"buildpacks"`
+	Config     map[string]interface{} `json:"config,omitempty"`
 }
 
 type UpdateBuildConfigRequest struct {
-	Buildpacks []*buildpacks.BuildpackInfo `json:"buildpacks"`
-	Config     map[string]interface{}      `json:"config,omitempty"`
+	Builder    string                 `json:"builder"`
+	Buildpacks []string               `json:"buildpacks"`
+	Config     map[string]interface{} `json:"config,omitempty"`
 }

+ 16 - 0
api/types/crd.go

@@ -0,0 +1,16 @@
+package types
+
+type DeleteCRDRequest struct {
+	Name      string `schema:"name" form:"required"`
+	Namespace string `schema:"namespace" form:"required"`
+	Group     string `schema:"group" form:"required"`
+	Version   string `schema:"version" form:"required"`
+	Resource  string `schema:"resource" form:"required"`
+}
+
+type StreamCRDRequest struct {
+	Namespace string `json:"namespace"`
+	Group     string `json:"group" form:"required"`
+	Version   string `json:"version" form:"required"`
+	Resource  string `json:"resource" form:"required"`
+}

+ 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"`
+}

+ 2 - 0
api/types/release.go

@@ -116,6 +116,8 @@ type NotificationConfig struct {
 	Enabled bool `json:"enabled"`
 	Success bool `json:"success"`
 	Failure bool `json:"failure"`
+
+	NotifLimit string `json:"notif_limit"`
 }
 
 type GetNotificationConfigResponse struct {

+ 6 - 0
cli/cmd/pack/pack.go

@@ -58,5 +58,11 @@ func (a *Agent) Build(opts *docker.BuildOpts, buildConfig *types.BuildConfig) er
 		}
 	}
 
+	if buildConfig != nil {
+		buildOpts.Builder = buildConfig.Builder
+		buildOpts.Buildpacks = buildConfig.Buildpacks
+		// FIXME: use all the config vars
+	}
+
 	return client.Build(context, buildOpts)
 }

ファイルの差分が大きいため隠しています
+ 14115 - 695
dashboard/package-lock.json


+ 2 - 1
dashboard/package.json

@@ -19,7 +19,7 @@
     "@visx/tooltip": "^1.3.0",
     "ace-builds": "^1.4.12",
     "anser": "^2.0.1",
-    "axios": "^0.20.0",
+    "axios": "^0.21.2",
     "brace": "^0.11.1",
     "clipboard": "^2.0.8",
     "cohere-js": "^1.0.19",
@@ -39,6 +39,7 @@
     "react-ace": "^9.1.3",
     "react-dom": "^16.13.1",
     "react-error-boundary": "^3.1.3",
+    "react-infinite-scroll-component": "^6.1.0",
     "react-modal": "^3.11.2",
     "react-router-dom": "^5.2.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;
+  }
+`;

+ 94 - 44
dashboard/src/components/ExpandableResource.tsx

@@ -1,58 +1,104 @@
-import React, { Component } from "react";
+import React, { Component, useContext, useEffect } from "react";
 import styled from "styled-components";
 import { Context } from "shared/Context";
-
 import ResourceTab from "./ResourceTab";
+import SaveButton from "./SaveButton";
+import { baseApi } from "shared/baseApi";
 
-type PropsType = {
+type Props = {
   resource: any;
+  button: any;
   handleClick?: () => void;
   selected?: boolean;
   isLast?: boolean;
   roundAllCorners?: boolean;
 };
 
-type StateType = any;
-
-export default class ExpandableResource extends Component<
-  PropsType,
-  StateType
-> {
-  render() {
-    let { resource } = this.props;
-    return (
-      <ResourceTab
-        label={resource.label}
-        name={resource.name}
-        status={{ label: resource.status }}
-      >
-        <ExpandedWrapper>
-          <StatusSection>
-            <StatusHeader>
-              <Status>
-                <Key>Status:</Key> {resource.status}
-              </Status>
-              <Timestamp>Updated {resource.timestamp}</Timestamp>
-            </StatusHeader>
-            {resource.message}
-          </StatusSection>
-          {Object.keys(this.props.resource.data).map(
-            (key: string, i: number) => {
-              return (
-                <Pair key={i}>
-                  <Key>{key}:</Key>
-                  {this.props.resource.data[key]}
-                </Pair>
-              );
-            }
-          )}
-        </ExpandedWrapper>
-      </ResourceTab>
-    );
-  }
-}
-
-ExpandableResource.contextType = Context;
+const ExpandableResource: React.FC<Props> = (props) => {
+  const { resource, button } = props;
+  const { currentCluster, currentProject } = useContext(Context);
+
+  const onSave = () => {
+    let projID = currentProject.id;
+    let clusterID = currentCluster.id;
+    let config = button.actions[0].delete.context.config;
+
+    // TODO: construct the endpoint scope, right now we're just using release scope
+    let uri = `/api/projects/${projID}/clusters/${clusterID}/namespaces/${resource.metadata.namespace}${button.actions[0].delete.relative_uri}`;
+
+    // compute the endpoint using button and target context
+    baseApi<
+      {
+        name: string;
+        namespace: string;
+        group: string;
+        version: string;
+        resource: string;
+      },
+      {}
+    >("DELETE", uri)(
+      "<token>",
+      {
+        name: resource.metadata.name,
+        namespace: resource.metadata.namespace,
+        group: config.group,
+        version: config.version,
+        resource: config.resource,
+      },
+      {}
+    )
+      .then((res) => {})
+      .catch((err) => console.log(err));
+  };
+
+  const readableDate = (s: string) => {
+    const ts = new Date(s);
+    const date = ts.toLocaleDateString();
+    const time = ts.toLocaleTimeString([], {
+      hour: "numeric",
+      minute: "2-digit",
+    });
+    return `${time} on ${date}`;
+  };
+
+  return (
+    <ResourceTab
+      label={resource.label}
+      name={resource.name}
+      status={{ label: resource.status }}
+    >
+      <ExpandedWrapper>
+        <StatusSection>
+          <StatusHeader>
+            <Status>
+              <Key>Status:</Key> {resource.status}
+            </Status>
+            <Timestamp>Updated {readableDate(resource.timestamp)}</Timestamp>
+          </StatusHeader>
+          {resource.message}
+        </StatusSection>
+        {Object.keys(resource.data).map((key: string, i: number) => {
+          return (
+            <Pair key={i}>
+              <Key>{key}:</Key>
+              {resource.data[key]}
+            </Pair>
+          );
+        })}
+        <StyledSaveButton
+          onClick={onSave}
+          clearPosition={true}
+          text={button.name}
+          helper={button.description}
+          statusPosition={"right"}
+          className="expanded-save-button"
+        />
+      </ExpandedWrapper>
+    </ResourceTab>
+  );
+};
+
+export default ExpandableResource;
 
 const Timestamp = styled.div`
   font-size: 12px;
@@ -97,3 +143,7 @@ const Key = styled.div`
   color: #ffffff;
   margin-right: 8px;
 `;
+
+const StyledSaveButton = styled(SaveButton)`
+  margin-top: 20px;
+`;

+ 14 - 13
dashboard/src/components/ProvisionerStatus.tsx

@@ -4,7 +4,7 @@ import { integrationList } from "shared/common";
 
 import loading from "assets/loading.gif";
 
-import styled from "styled-components";
+import styled, { keyframes } from "styled-components";
 
 type Props = {
   modules: TFModule[];
@@ -15,6 +15,7 @@ export interface TFModule {
   kind: string;
   status: string;
   created_at: string;
+  updated_at: string;
   global_errors?: TFResourceError[];
   got_desired: boolean;
   // optional resources, if not created
@@ -22,7 +23,7 @@ export interface TFModule {
 }
 
 export interface TFResourceError {
-  errored_out: boolean;
+  errored_out?: boolean;
   error_context?: string;
 }
 
@@ -199,6 +200,16 @@ const ExpandedError = styled.div`
   padding-bottom: 17px;
 `;
 
+const movingGradient = keyframes`
+  0% {
+      background-position: left bottom;
+  }
+
+  100% {
+      background-position: right bottom;
+  }
+`;
+
 const LoadingFill = styled.div<{ width: string; status: string }>`
   width: ${(props) => props.width};
   background: ${(props) =>
@@ -209,19 +220,9 @@ const LoadingFill = styled.div<{ width: string; status: string }>`
       : "linear-gradient(to right, #8ce1ff, #616FEE)"};
   height: 100%;
   background-size: 250% 100%;
-  animation: moving-gradient 2s infinite;
+  animation: ${movingGradient} 2s infinite;
   animation-timing-function: ease-in-out;
   animation-direction: alternate;
-
-  @keyframes moving-gradient {
-    0% {
-        background-position: left bottom;
-    }
-
-    100% {
-        background-position: right bottom;
-    }
-  }​
 `;
 
 const StatusIcon = styled.div<{ successful?: boolean }>`

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

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

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

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

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

@@ -23,6 +23,7 @@ type StateType = {};
 export default class Selector extends Component<PropsType, StateType> {
   state = {
     expanded: false,
+    showTooltip: false,
   };
 
   wrapperRef: any = React.createRef();
@@ -169,6 +170,8 @@ export default class Selector extends Component<PropsType, StateType> {
           expanded={this.state.expanded}
           width={this.props.width}
           height={this.props.height}
+          onMouseEnter={() => this.setState({ showTooltip: true })}
+          onMouseLeave={() => this.setState({ showTooltip: false })}
         >
           <Flex>
             {this.renderIcon()}
@@ -182,6 +185,15 @@ export default class Selector extends Component<PropsType, StateType> {
           </Flex>
           <i className="material-icons">arrow_drop_down</i>
         </MainSelector>
+        {this.state.showTooltip && (
+          <Tooltip>
+            {activeValue
+              ? activeValue === ""
+                ? "All"
+                : this.getLabel(activeValue)
+              : this.props.placeholder}
+          </Tooltip>
+        )}
         {this.renderDropdown()}
       </StyledSelector>
     );
@@ -206,6 +218,7 @@ const ScrollBuffer = styled.div`
 const Flex = styled.div`
   display: flex;
   align-items: center;
+  width: 85%;
 `;
 
 const Icon = styled.div`
@@ -263,16 +276,18 @@ const NewOption = styled.div`
   }
 `;
 
-const Option = styled.div<{
+type OptionProps = {
   selected: boolean;
   lastItem: boolean;
   height: string;
-}>`
+};
+
+const Option = styled.div`
   width: 100%;
   border-top: 1px solid #00000000;
   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;
   align-items: center;
   display: flex;
@@ -283,7 +298,7 @@ const Option = styled.div<{
   white-space: nowrap;
   overflow: hidden;
   text-overflow: ellipsis;
-  background: ${(props) => (props.selected ? "#ffffff11" : "")};
+  background: ${(props: OptionProps) => (props.selected ? "#ffffff11" : "")};
 
   :hover {
     background: #ffffff22;
@@ -353,3 +368,35 @@ const MainSelector = styled.div`
     }) => (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>
         <Splitter>|</Splitter>
         <Helper>
-          Sorry for the inconvinience! The Porter team has been notified
+          Sorry for the inconvenience! The Porter team has been notified.
         </Helper>
       </Flex>
     </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;
+`;

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

@@ -0,0 +1,301 @@
+import React, { useContext, useEffect, useMemo, useState } from "react";
+import styled from "styled-components";
+import backArrow from "assets/back_arrow.png";
+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";
+
+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;
+}> = ({ event, clearSelectedEvent }) => {
+  const { currentProject, currentCluster } = useContext(Context);
+  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>
+      <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>
+        Pod {event.name} crashed
+      </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 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`
+  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;
+`;

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

@@ -0,0 +1,153 @@
+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);
+      setLogs(logsData.logs);
+      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;
+  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,
+  };
+};

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

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

+ 3 - 5
dashboard/src/components/image-selector/TagList.tsx

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

+ 83 - 3
dashboard/src/components/porter-form/field-components/ResourceList.tsx

@@ -1,18 +1,98 @@
-import React from "react";
+import React, { useEffect, useContext, useState } from "react";
 import { ResourceListField } from "../types";
+import { Context } from "shared/Context";
+import { useWebsockets } from "shared/hooks/useWebsockets";
 import ExpandableResource from "../../ExpandableResource";
+import { PorterFormContext } from "components/porter-form/PorterFormContextProvider";
 import styled from "styled-components";
 
 const ResourceList: React.FC<ResourceListField> = (props) => {
+  const { currentCluster, currentProject } = useContext(Context);
+  const { formState } = useContext(PorterFormContext);
+  const [resourceList, updateResourceList] = useState<any[]>(props.value);
+
+  const {
+    newWebsocket,
+    openWebsocket,
+    closeAllWebsockets,
+    closeWebsocket,
+  } = useWebsockets();
+
+  const sortAndUpdateResources = (list: any[]) => {
+    list.sort((a, b) => {
+      return b.timestamp.localeCompare(a.timestamp);
+    });
+
+    updateResourceList(list);
+  };
+
+  useEffect(() => {
+    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?`;
+    apiEndpoint += `resource=${resource}&group=${group}&version=${version}`;
+
+    const wsConfig = {
+      onmessage(evt: MessageEvent) {
+        let { data, kind } = JSON.parse(evt.data);
+
+        // parse for name and label, which uniquely identify a resource
+        for (let [key] of Object.entries(data)) {
+          // check the name and label in the value
+          let { name, label } = data[key][0];
+
+          // attempt to find a corresponding name and label in the current array
+          let foundMatch = false;
+
+          resourceList.forEach((resource, index) => {
+            if (resource.name == name && resource.label == label) {
+              foundMatch = true;
+
+              switch (kind) {
+                case "update":
+                case "create":
+                  // replace this resource in the list
+                  resourceList[index] = data[key][0];
+                  break;
+                case "delete":
+                  // remove this resource from the list
+                  resourceList.splice(index, 1);
+                  break;
+                default:
+              }
+            }
+          });
+
+          if (!foundMatch && kind != "delete") {
+            // add this resource to the list
+            resourceList.push(data[key][0]);
+          }
+        }
+
+        sortAndUpdateResources([...resourceList]);
+      },
+      onerror() {
+        closeWebsocket("stream");
+      },
+    };
+
+    newWebsocket("stream", apiEndpoint, wsConfig);
+    openWebsocket("stream");
+
+    return () => {
+      closeAllWebsockets();
+    };
+  }, []);
+
   return (
     <ResourceListWrapper>
-      {props.value?.map((resource: any, i: number) => {
+      {resourceList?.map((resource: any, i: number) => {
         if (resource.data) {
           return (
             <ExpandableResource
               key={i}
+              button={props?.settings?.options["resource-button"]}
               resource={resource}
-              isLast={i === props.value.length - 1}
+              isLast={i === resourceList.length - 1}
               roundAllCorners={true}
             />
           );

+ 12 - 0
dashboard/src/components/porter-form/types.ts

@@ -39,6 +39,18 @@ export interface ServiceIPListField extends GenericField {
 export interface ResourceListField extends GenericField {
   type: "resource-list";
   value: any[];
+  context?: {
+    config?: {
+      group: string
+      version: string
+      resource: string
+    }
+  },
+  settings?: {
+    options?: {
+      "resource-button": any,
+    }
+  }
 }
 
 export interface VeleroBackupField extends GenericField {

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

@@ -391,7 +391,7 @@ class Home extends Component<PropsType, StateType> {
           </>
         )}
 
-        <ViewWrapper>
+        <ViewWrapper id="HomeViewWrapper">
           <Navbar
             logOut={this.props.logOut}
             currentView={this.props.currentRoute} // For form feedback

+ 12 - 0
dashboard/src/main/home/ModalHandler.tsx

@@ -15,6 +15,7 @@ import RedirectToOnboardingModal from "./modals/RedirectToOnboardingModal";
 import UsageWarningModal from "./modals/UsageWarningModal";
 import api from "shared/api";
 import { AxiosError } from "axios";
+import SkipOnboardingModal from "./modals/SkipProvisioningModal";
 
 const ModalHandler: React.FC<{
   setRefreshClusters: (x: boolean) => void;
@@ -187,6 +188,17 @@ const ModalHandler: React.FC<{
           <UsageWarningModal />
         </Modal>
       )}
+
+      {modal === "SkipOnboardingModal" && (
+        <Modal
+          onRequestClose={() => setCurrentModal(null, null)}
+          width="600px"
+          height="240px"
+          title="Would you like to skip project setup?"
+        >
+          <SkipOnboardingModal />
+        </Modal>
+      )}
     </>
   );
 };

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

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

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

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

+ 115 - 99
dashboard/src/main/home/cluster-dashboard/expanded-chart/ExpandedChart.tsx

@@ -82,6 +82,7 @@ const ExpandedChart: React.FC<Props> = (props) => {
   const [isLoadingChartData, setIsLoadingChartData] = useState<boolean>(true);
   const [showRepoTooltip, setShowRepoTooltip] = useState(false);
   const [isAuthorized] = useAuth();
+  const [fullScreenLogs, setFullScreenLogs] = useState<boolean>(false);
 
   const {
     newWebsocket,
@@ -346,12 +347,12 @@ const ExpandedChart: React.FC<Props> = (props) => {
   const renderTabContents = (currentTab: string) => {
     let { setSidebar } = props;
     let chart = currentChart;
-
+    console.log("CONTROLLERS", controllers);
     switch (currentTab) {
       case "metrics":
         return <MetricsSection currentChart={chart} />;
       case "events":
-        return <EventsTab currentChart={chart} />;
+        return <EventsTab controllers={controllers} />;
       case "status":
         if (isLoadingChartData) {
           return (
@@ -383,7 +384,12 @@ const ExpandedChart: React.FC<Props> = (props) => {
             </Placeholder>
           );
         } else {
-          return <StatusSection currentChart={chart} />;
+          return (
+            <StatusSection
+              currentChart={chart}
+              setFullScreenLogs={() => setFullScreenLogs(true)}
+            />
+          );
         }
       case "settings":
         return (
@@ -662,102 +668,112 @@ const ExpandedChart: React.FC<Props> = (props) => {
 
   return (
     <>
-      <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>
+      {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 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 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 () => {
-      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 (
       <Placeholder>
-        <Loading />
+        <i className="material-icons">search</i>
+        We coulnd't find any controllers for this application.
       </Placeholder>
     );
   }
 
-  if (eventData.length === 0) {
+  if (!hasPorterAgent) {
     return (
       <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>
     );
   }
 
-  if (selectedEvent !== null) {
+  if (currentEvent) {
     return (
-      <EventDetail
-        container={eventData[selectedEvent]}
-        resetSelection={() => {
-          setSelectedEvent(null);
-          return null;
-        }}
+      <SubEventsList
+        event={currentEvent}
+        clearSelectedEvent={() => setCurrentEvent(null)}
       />
     );
   }
 
   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;
 
+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`
-  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`
@@ -191,12 +233,12 @@ const InstallPorterAgentButton = styled.button`
   cursor: pointer;
   font-family: "Work Sans", sans-serif;
   border: none;
-  border-radius: 20px;
+  border-radius: 5px;
   color: white;
   height: 35px;
   padding: 0px 8px;
   padding-bottom: 1px;
-  margin-top: 10px;
+  margin-top: 20px;
   font-weight: 500;
   padding-right: 15px;
   overflow: hidden;
@@ -205,14 +247,11 @@ const InstallPorterAgentButton = styled.button`
   box-shadow: 0 5px 8px 0px #00000010;
   cursor: ${(props: { disabled?: boolean }) =>
     props.disabled ? "not-allowed" : "pointer"};
-
   background: ${(props: { disabled?: boolean }) =>
-    props.disabled ? "#aaaabbee" : "#616FEEcc"};
+    props.disabled ? "#aaaabbee" : "#5561C0"};
   :hover {
-    background: ${(props: { disabled?: boolean }) =>
-      props.disabled ? "" : "#505edddd"};
+    filter: ${(props) => (!props.disabled ? "brightness(120%)" : "")};
   }
-
   > i {
     color: white;
     width: 18px;
@@ -228,18 +267,22 @@ const InstallPorterAgentButton = styled.button`
 `;
 
 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%;
-  min-height: 300px;
-  height: 40vh;
   display: flex;
   align-items: center;
   justify-content: center;
-  color: #ffffff44;
-  font-size: 14px;
 
   > i {
     font-size: 18px;
-    margin-right: 10px;
+    margin-right: 8px;
   }
 `;
 
@@ -249,16 +292,3 @@ const Header = styled.div`
   font-size: 16px;
   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;
-`;

+ 1 - 0
dashboard/src/main/home/cluster-dashboard/expanded-chart/status/Logs.tsx

@@ -434,6 +434,7 @@ const LogStream = styled.div`
   flex: 1;
   float: right;
   height: 100%;
+  font-size: 13px;
   background: #121318;
   user-select: text;
   max-width: 65%;

+ 107 - 1
dashboard/src/main/home/cluster-dashboard/expanded-chart/status/StatusSection.tsx

@@ -5,6 +5,7 @@ import api from "shared/api";
 import { Context } from "shared/Context";
 import { ChartType, StorageType } from "shared/types";
 import Loading from "components/Loading";
+import backArrow from "assets/back_arrow.png";
 
 import Logs from "./Logs";
 import ControllerTab from "./ControllerTab";
@@ -12,10 +13,14 @@ import ControllerTab from "./ControllerTab";
 type Props = {
   selectors?: string[];
   currentChart: ChartType;
+  fullscreen?: boolean;
+  setFullScreenLogs?: any;
 };
 
 const StatusSectionFC: React.FunctionComponent<Props> = ({
   currentChart,
+  fullscreen,
+  setFullScreenLogs,
   selectors,
 }) => {
   const [selectedPod, setSelectedPod] = useState<any>({});
@@ -126,11 +131,103 @@ const StatusSectionFC: React.FunctionComponent<Props> = ({
     );
   };
 
-  return <StyledStatusSection>{renderStatusSection()}</StyledStatusSection>;
+  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>
+      )}
+    </>
+  );
 };
 
 export default StatusSectionFC;
 
+const FullScreenButton = styled.div<{ top?: string }>`
+  position: absolute;
+  top: ${(props) => props.top || "10px"};
+  right: 10px;
+  width: 24px;
+  height: 24px;
+  cursor: pointer;
+  display: flex;
+  justify-content: center;
+  align-items: center;
+  border-radius: 5px;
+  background: #ffffff11;
+  border: 1px solid #aaaabb;
+
+  :hover {
+    background: #ffffff22;
+  }
+
+  > i {
+    font-size: 14px;
+  }
+`;
+
+const BackButton = styled.div`
+  display: flex;
+  width: 30px;
+  z-index: 999;
+  cursor: pointer;
+  height: 30px;
+  align-items: center;
+  margin-right: 15px;
+  justify-content: center;
+  cursor: pointer;
+  border: 1px solid #ffffff55;
+  border-radius: 100px;
+  background: #ffffff11;
+
+  > i {
+    font-size: 18px;
+  }
+
+  :hover {
+    background: #ffffff22;
+    > img {
+      opacity: 1;
+    }
+  }
+`;
+
+const BackButtonImg = styled.img`
+  width: 12px;
+  opacity: 0.75;
+`;
+
+const AbsoluteTitle = styled.div`
+  position: absolute;
+  top: 0px;
+  left: 0px;
+  width: 100%;
+  height: 60px;
+  display: flex;
+  align-items: center;
+  padding-left: 20px;
+  font-size: 18px;
+  font-weight: 500;
+  user-select: text;
+`;
+
 const TabWrapper = styled.div`
   width: 35%;
   min-width: 250px;
@@ -163,6 +260,15 @@ const StyledStatusSection = styled.div`
   }
 `;
 
+const FullScreen = styled.div`
+  position: absolute;
+  top: 0;
+  left: 0;
+  width: 100%;
+  height: 100%;
+  padding-top: 60px;
+`;
+
 const Wrapper = styled.div`
   width: 100%;
   height: 100%;

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

@@ -16,7 +16,7 @@ import TitleSection from "components/TitleSection";
 
 import { pushFiltered, pushQueryParams } from "shared/routing";
 import { withAuth, WithAuthProps } from "shared/auth/AuthorizationHoc";
-import { SharedStatus } from "../onboarding/steps/ProvisionResources/forms/SharedStatus";
+import { StatusPage } from "../onboarding/steps/ProvisionResources/forms/StatusPage";
 
 type PropsType = RouteComponentProps &
   WithAuthProps & {
@@ -107,10 +107,10 @@ class Dashboard extends Component<PropsType, StateType> {
   renderTabContents = () => {
     if (this.currentTab() === "provisioner") {
       return (
-        <SharedStatus
+        <StatusPage
           filter={[]}
           project_id={this.props.projectId}
-          setInfraStatus={(val: string) => null}
+          setInfraStatus={() => null}
         />
       );
     } else if (this.currentTab() === "create-cluster") {

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

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

+ 58 - 0
dashboard/src/main/home/modals/SkipProvisioningModal.tsx

@@ -0,0 +1,58 @@
+import InputRow from "components/form-components/InputRow";
+import SaveButton from "components/SaveButton";
+import React, { useContext } from "react";
+import { Context } from "shared/Context";
+import styled from "styled-components";
+
+/**
+ * If user goes to /onboarding and has clusters, the Onboarding component
+ * will open this modal to let user skip onboarding and keep using porter.
+ */
+const SkipOnboardingModal = () => {
+  const { currentModalData, setCurrentModal } = useContext(Context);
+
+  return (
+    <>
+      <Subtitle>
+        Porter has detected an existing Kubernetes cluster that was connected
+        via the CLI. For custom setups, you can skip the project setup flow.
+      </Subtitle>
+      <Subtitle>Do you want to skip project setup?</Subtitle>
+      <ActionsWrapper>
+        <ActionButton
+          text="Yes, skip setup"
+          color="#616FEEcc"
+          onClick={() =>
+            typeof currentModalData?.skipOnboarding === "function" &&
+            currentModalData.skipOnboarding()
+          }
+          status={""}
+          clearPosition
+        />
+      </ActionsWrapper>
+    </>
+  );
+};
+
+export default SkipOnboardingModal;
+
+const ActionButton = styled(SaveButton)``;
+
+const ActionsWrapper = styled.div`
+  position: absolute;
+  bottom: 14px;
+  right: 14px;
+  display: flex;
+  ${ActionButton} {
+    margin-left: 5px;
+  }
+`;
+
+const Subtitle = styled.div`
+  margin-top: 23px;
+  font-family: "Work Sans", sans-serif;
+  font-size: 14px;
+  color: #aaaabb;
+  overflow: hidden;
+  margin-bottom: -10px;
+`;

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

@@ -4,8 +4,7 @@ import styled from "styled-components";
 import { Context } from "shared/Context";
 import discordLogo from "../../../assets/discord.svg";
 
-type PropsType = {
-};
+type PropsType = {};
 
 type StateType = {
   showHelpDropdown: boolean;
@@ -13,7 +12,7 @@ type StateType = {
 
 export default class Help extends Component<PropsType, StateType> {
   state = {
-      showHelpDropdown: false,
+    showHelpDropdown: false,
   };
 
   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>
-            <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
             </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>
         </>
       );
@@ -77,31 +77,43 @@ export default class Help extends Component<PropsType, StateType> {
 Help.contextType = Context;
 
 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;
+  }
+
+  :hover {
+    color: #ffffff;
+
+    > img {
+      opacity: 100%;
+    }
+
     > i {
-        opacity: 50%;
-        color: white;
-        margin-right: 7px;
-        font-size: 20px;
-        cursor: pointer;
+      opacity: 100%;
     }
-`
+  }
+`;
 
 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`
   position: fixed;
@@ -203,9 +215,9 @@ const FeedbackButton = styled(NavButton)`
 `;
 
 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() {
     return (
       <StyledNavbar>
-        <Help/>
+        <Help />
         {this.renderFeedbackButton()}
         <NavButton
           selected={this.state.showDropdown}
@@ -245,18 +245,18 @@ const StyledNavbar = 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`
   display: flex;

+ 32 - 0
dashboard/src/main/home/onboarding/Onboarding.tsx

@@ -115,6 +115,38 @@ const Onboarding = () => {
     }
   }, [context.user]);
 
+  const skipOnboarding = () => {
+    OFState.actions.goTo("clean_up");
+  };
+
+  const checkIfUserHasClusters = async () => {
+    const { setCurrentModal, currentProject } = context;
+
+    const project_id = currentProject?.id;
+
+    try {
+      if (typeof project_id !== "number") {
+        return;
+      }
+
+      const clusters = await api
+        .getClusters("<token>", {}, { id: project_id })
+        .then((res) => res?.data);
+
+      const hasClusters = Array.isArray(clusters) && clusters.length;
+
+      if (hasClusters) {
+        setCurrentModal("SkipOnboardingModal", { skipOnboarding });
+      }
+    } catch (error) {
+      console.error(error);
+    }
+  };
+
+  useEffect(() => {
+    checkIfUserHasClusters();
+  }, [context?.currentProject?.id]);
+
   return (
     <StyledOnboarding>{isLoading ? <Loading /> : <Routes />}</StyledOnboarding>
   );

+ 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 styled from "styled-components";
 import { SupportedProviders } from "../types";
@@ -13,6 +13,7 @@ export type ProviderSelectorProps = {
     icon: string;
     label: string;
   }[];
+  defaultOption?: string;
 };
 
 export const registryOptions = [
@@ -68,22 +69,33 @@ export const provisionerOptionsWithExternal = [
 const ProviderSelector: React.FC<ProviderSelectorProps> = ({
   selectProvider,
   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 (
     <>
       <Br />
       <Selector
-        activeValue={provider}
+        activeValue={activeProvider}
         options={options}
         placeholder="Select a cloud provider"
         setActiveValue={(provider) => {
+          setIsDirty(true);
           setProvider(provider);
           selectProvider(provider as SupportedProviders);
         }}

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

@@ -76,6 +76,21 @@ export const StateHandler = proxy({
         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) => {
       if (provider === StateHandler.connected_registry?.provider) {
         return;

+ 6 - 2
dashboard/src/main/home/onboarding/state/StepHandler.ts

@@ -16,15 +16,17 @@ type Step = {
       skip?: string;
       continue?: 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 = {
   skip?: string;
   continue: string;
   go_back?: string;
+  continue_with_current?: string;
 };
 
 export type FlowType = {
@@ -53,12 +55,14 @@ const flow: FlowType = {
       on: {
         skip: "provision_resources",
         continue: "connect_registry.credentials",
+        continue_with_current: "provision_resources",
         go_back: "connect_source",
       },
       execute: {
         on: {
           skip: "skipRegistryConnection",
           continue: "saveRegistryProvider",
+          continue_with_current: "saveRegistryAndContinue",
         },
       },
       substeps: {
@@ -112,7 +116,7 @@ const flow: FlowType = {
          * has a proper way of listing the registries and
          * manage them inside the step
          */
-        // go_back: "connect_registry",
+        go_back: "connect_registry",
       },
       execute: {
         on: {

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

@@ -1,7 +1,7 @@
 import Helper from "components/form-components/Helper";
 import SaveButton from "components/SaveButton";
 import TitleSection from "components/TitleSection";
-import React from "react";
+import React, { useEffect, useMemo, useState } from "react";
 import { useParams } from "react-router";
 
 import styled from "styled-components";
@@ -13,16 +13,66 @@ import backArrow from "assets/back_arrow.png";
 import FormFlowWrapper from "./forms/FormFlow";
 import { OFState } from "../../state";
 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 snap = useSnapshot(OFState);
   const { step } = useParams<any>();
+  const [connectedRegistries, setConnectedRegistries] = useState(null);
+  const [isLoading, setIsLoading] = useState(true);
 
   const currentProvider = snap.StateHandler.connected_registry?.provider;
 
   const enableGoBack =
     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 = () => {
     OFState.actions.nextStep("go_back");
   };
@@ -35,6 +85,27 @@ const ConnectRegistry: React.FC<{}> = ({}) => {
     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 (
     <Div>
       {enableGoBack && (
@@ -62,22 +133,49 @@ const ConnectRegistry: React.FC<{}> = ({}) => {
           : "Link to an existing Docker registry or continue."}
       </Helper>
 
-      {step ? (
+      {!isLoading && step ? (
         <FormFlowWrapper currentStep={step} />
       ) : (
         <>
           <ProviderSelector
+            defaultOption={
+              Array.isArray(connectedRegistries) && connectedRegistries.length
+                ? "use_current"
+                : "skip"
+            }
             selectProvider={(provider) => {
               if (provider !== "external") {
                 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
             text="Continue"
             disabled={false}
-            onClick={() => handleSkip()}
+            onClick={() => {
+              if (
+                Array.isArray(connectedRegistries) &&
+                connectedRegistries.length
+              ) {
+                handleContinueWithCurrent();
+              } else {
+                handleSkip();
+              }
+            }}
             status={""}
             makeFlush={true}
             clearPosition={true}
@@ -92,6 +190,10 @@ const ConnectRegistry: React.FC<{}> = ({}) => {
 
 export default ConnectRegistry;
 
+const IntegrationList = styled.div`
+  margin-top: 14px;
+`;
+
 const Div = styled.div`
   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;
+`;

+ 1 - 1
dashboard/src/main/home/onboarding/steps/ConnectSource.tsx

@@ -96,7 +96,7 @@ const ConnectSource: React.FC<{
           </Helper>
         </>
       )}
-      {!isLoading && accountData?.accounts.length && (
+      {!isLoading && accountData?.accounts?.length && (
         <>
           <Helper>Porter currently has access to:</Helper>
           <List>

+ 6 - 3
dashboard/src/main/home/onboarding/steps/ProvisionResources/ProvisionResources.tsx

@@ -12,7 +12,7 @@ import ProviderSelector, {
 import FormFlowWrapper from "./forms/FormFlow";
 import ConnectExternalCluster from "./forms/_ConnectExternalCluster";
 import backArrow from "assets/back_arrow.png";
-import { SharedStatus } from "./forms/SharedStatus";
+import { StatusPage } from "./forms/StatusPage";
 import { useSnapshot } from "valtio";
 import { OFState } from "../../state";
 
@@ -74,7 +74,10 @@ const ProvisionResources: React.FC<Props> = () => {
           <Br height="15px" />
           <SaveButton
             text="Resolve Errors"
-            status="Encountered errors while provisioning."
+            status={
+              infraStatus?.description ||
+              "Encountered errors while provisioning."
+            }
             disabled={false}
             onClick={() => handleGoBack(infraStatus.description)}
             makeFlush={true}
@@ -108,7 +111,7 @@ const ProvisionResources: React.FC<Props> = () => {
       case "status":
         return (
           <>
-            <SharedStatus
+            <StatusPage
               project_id={project?.id}
               filter={getFilterOpts()}
               setInfraStatus={setInfraStatus}

+ 1 - 1
dashboard/src/main/home/onboarding/steps/ProvisionResources/forms/FormFlow.tsx

@@ -125,7 +125,7 @@ const FormFlowWrapper: React.FC<Props> = ({ currentStep }) => {
           {FormTitle[provider] && <img src={FormTitle[provider].icon} />}
           {FormTitle[provider] && FormTitle[provider].label}
         </FormHeader>
-        <GuideButton href={FormTitle[provider].doc} target="_blank">
+        <GuideButton href={FormTitle[provider]?.doc} target="_blank">
           <i className="material-icons-outlined">help</i>
           Guide
         </GuideButton>

+ 0 - 365
dashboard/src/main/home/onboarding/steps/ProvisionResources/forms/SharedStatus.tsx

@@ -1,365 +0,0 @@
-import ProvisionerStatus, {
-  TFModule,
-  TFResource,
-  TFResourceError,
-} from "components/ProvisionerStatus";
-import React, { useEffect, useState } from "react";
-import api from "shared/api";
-import { useWebsockets } from "shared/hooks/useWebsockets";
-
-export const SharedStatus: React.FC<{
-  setInfraStatus: (status: { hasError: boolean; description?: string }) => void;
-  project_id: number;
-  filter: string[];
-}> = ({ setInfraStatus, project_id, filter }) => {
-  const {
-    newWebsocket,
-    openWebsocket,
-    closeWebsocket,
-    closeAllWebsockets,
-  } = useWebsockets();
-
-  const [tfModules, setTFModules] = useState<TFModule[]>([]);
-  const [isLoadingState, setIsLoadingState] = useState(true);
-
-  const updateTFModules = (
-    index: number,
-    addedResources: TFResource[],
-    erroredResources: TFResource[],
-    globalErrors: TFResourceError[],
-    gotDesired?: boolean
-  ) => {
-    if (!tfModules[index]?.resources) {
-      tfModules[index].resources = [];
-    }
-
-    if (!tfModules[index]?.global_errors) {
-      tfModules[index].global_errors = [];
-    }
-
-    if (gotDesired) {
-      tfModules[index].got_desired = true;
-    }
-
-    let resources = tfModules[index].resources;
-
-    // construct map of tf resources addresses to indices
-    let resourceAddrMap = new Map<string, number>();
-
-    tfModules[index].resources.forEach((resource, index) => {
-      resourceAddrMap.set(resource.addr, index);
-    });
-
-    for (let addedResource of addedResources) {
-      // if exists, update state to provisioned
-      if (resourceAddrMap.has(addedResource.addr)) {
-        let currResource = resources[resourceAddrMap.get(addedResource.addr)];
-        addedResource.errored = currResource.errored;
-        resources[resourceAddrMap.get(addedResource.addr)] = addedResource;
-      } else {
-        resources.push(addedResource);
-        resourceAddrMap.set(addedResource.addr, resources.length - 1);
-
-        // if the resource is being added but there's not a desired state, re-query for the
-        // desired state
-        if (!tfModules[index].got_desired) {
-          updateDesiredState(index, tfModules[index]);
-        }
-      }
-    }
-
-    for (let erroredResource of erroredResources) {
-      // if exists, update state to provisioned
-      if (resourceAddrMap.has(erroredResource.addr)) {
-        resources[resourceAddrMap.get(erroredResource.addr)] = erroredResource;
-      } else {
-        resources.push(erroredResource);
-        resourceAddrMap.set(erroredResource.addr, resources.length - 1);
-      }
-    }
-
-    tfModules[index].global_errors = [
-      ...tfModules[index].global_errors,
-      ...globalErrors,
-    ];
-
-    setTFModules([...tfModules]);
-  };
-
-  useEffect(() => {
-    if (isLoadingState) {
-      return;
-    }
-    // recompute tf module state each time, to see if infra is ready
-    if (tfModules.length > 0) {
-      // see if all tf modules are in a "created" state
-      if (
-        tfModules.filter((val) => val.status == "created").length ==
-        tfModules.length
-      ) {
-        setInfraStatus({
-          hasError: false,
-        });
-        return;
-      }
-
-      if (
-        tfModules.filter((val) => val.status == "error").length ==
-        tfModules.length
-      ) {
-        setInfraStatus({
-          hasError: true,
-          description: "Encountered error while provisioning",
-        });
-        return;
-      }
-
-      // otherwise, check that all resources in each module are provisioned. Each module
-      // must have more than one resource
-      let numModulesSuccessful = 0;
-      let numModulesErrored = 0;
-
-      for (let tfModule of tfModules) {
-        if (tfModule.status == "created") {
-          numModulesSuccessful++;
-        } else if (tfModule.status == "error") {
-          numModulesErrored++;
-        } else {
-          let resLength = tfModule.resources?.length;
-          if (resLength > 0) {
-            numModulesSuccessful +=
-              tfModule.resources.filter((resource) => resource.provisioned)
-                .length == resLength
-                ? 1
-                : 0;
-
-            // if there's a global error, or the number of resources that errored_out is
-            // greater than 0, this resource is in an error state
-            numModulesErrored +=
-              tfModule.global_errors?.length > 0 ||
-              tfModule.resources.filter(
-                (resource) => resource.errored?.errored_out
-              ).length > 0
-                ? 1
-                : 0;
-          } else if (tfModule.global_errors?.length > 0) {
-            numModulesErrored += 1;
-          }
-        }
-      }
-
-      if (numModulesSuccessful == tfModules.length) {
-        setInfraStatus({
-          hasError: false,
-        });
-      } else if (numModulesErrored + numModulesSuccessful == tfModules.length) {
-        // otherwise, if all modules are either in an error state or successful,
-        // set the status to error
-        setInfraStatus({
-          hasError: true,
-        });
-      }
-    } else {
-      setInfraStatus(null);
-    }
-  }, [tfModules, isLoadingState]);
-
-  const setupInfraWebsocket = (
-    websocketID: string,
-    module: TFModule,
-    index: number
-  ) => {
-    let apiPath = `/api/projects/${project_id}/infras/${module.id}/logs`;
-
-    const wsConfig = {
-      onopen: () => {
-        console.log(`connected to websocket: ${websocketID}`);
-      },
-      onmessage: (evt: MessageEvent) => {
-        // parse the data
-        let parsedData = JSON.parse(evt.data);
-
-        let addedResources: TFResource[] = [];
-        let erroredResources: TFResource[] = [];
-        let globalErrors: TFResourceError[] = [];
-
-        for (let streamVal of parsedData) {
-          let streamValData = JSON.parse(streamVal?.Values?.data);
-
-          switch (streamValData?.type) {
-            case "apply_complete":
-              addedResources.push({
-                addr: streamValData?.hook?.resource?.addr,
-                provisioned: true,
-                errored: {
-                  errored_out: false,
-                },
-              });
-
-              break;
-            case "diagnostic":
-              if (streamValData["@level"] == "error") {
-                if (streamValData?.hook?.resource?.addr != "") {
-                  erroredResources.push({
-                    addr: streamValData?.hook?.resource?.addr,
-                    provisioned: false,
-                    errored: {
-                      errored_out: true,
-                      error_context: streamValData["@message"],
-                    },
-                  });
-                } else {
-                  globalErrors.push({
-                    errored_out: true,
-                    error_context: streamValData["@message"],
-                  });
-                }
-              }
-            case "change_summary":
-              if (streamValData.changes.add != 0) {
-                updateDesiredState(index, module);
-              }
-            default:
-          }
-        }
-
-        updateTFModules(index, addedResources, erroredResources, globalErrors);
-      },
-
-      onclose: () => {
-        console.log(`closing websocket: ${websocketID}`);
-      },
-
-      onerror: (err: ErrorEvent) => {
-        console.log(err);
-        closeWebsocket(websocketID);
-      },
-    };
-
-    newWebsocket(websocketID, apiPath, wsConfig);
-    openWebsocket(websocketID);
-  };
-
-  const mergeCurrentAndDesired = (
-    index: number,
-    desired: any,
-    currentMap: Map<string, string>
-  ) => {
-    // map desired state to list of resources
-    var addedResources: TFResource[] = desired?.map((val: any) => {
-      return {
-        addr: val?.addr,
-        provisioned: currentMap.has(val?.addr),
-        errored: {
-          errored_out: val?.errored?.errored_out,
-          error_context: val?.errored?.error_context,
-        },
-      };
-    });
-
-    updateTFModules(index, addedResources, [], [], true);
-  };
-
-  const updateDesiredState = (index: number, val: TFModule) => {
-    setIsLoadingState(true);
-    api
-      .getInfraDesired(
-        "<token>",
-        {},
-        { project_id: project_id, infra_id: val?.id }
-      )
-      .then((resDesired) => {
-        api
-          .getInfraCurrent(
-            "<token>",
-            {},
-            { project_id: project_id, infra_id: val?.id }
-          )
-          .then((resCurrent) => {
-            var desired = resDesired.data;
-            var current = resCurrent.data;
-
-            // convert current state to a lookup table
-            var currentMap: Map<string, string> = new Map();
-
-            current?.resources?.forEach((val: any) => {
-              currentMap.set(val?.type + "." + val?.name, "");
-            });
-
-            mergeCurrentAndDesired(index, desired, currentMap);
-          })
-          .catch((err) => {
-            var desired = resDesired.data;
-            var currentMap: Map<string, string> = new Map();
-
-            // merge with empty current map
-            mergeCurrentAndDesired(index, desired, currentMap);
-          })
-          .finally(() => {
-            setIsLoadingState(true);
-          });
-      })
-      .catch((err) => {
-        console.log(err);
-        setIsLoadingState(true);
-      });
-  };
-
-  useEffect(() => {
-    api.getInfra("<token>", {}, { project_id: project_id }).then((res) => {
-      var matchedInfras: Map<string, any> = new Map();
-
-      res.data.forEach((infra: any) => {
-        // if filter list is empty, add infra automatically
-        if (filter.length == 0) {
-          matchedInfras.set(infra.kind + "-" + infra.id, infra);
-        } else if (
-          filter.includes(infra.kind) &&
-          (matchedInfras.get(infra.Kind)?.id || 0 < infra.id)
-        ) {
-          matchedInfras.set(infra.kind, infra);
-        }
-      });
-
-      // query for desired and current state, and convert to tf module
-      matchedInfras.forEach((infra: any) => {
-        var module: TFModule = {
-          id: infra.id,
-          kind: infra.kind,
-          status: infra.status,
-          got_desired: false,
-          created_at: infra.created_at,
-        };
-
-        tfModules.push(module);
-      });
-
-      if (tfModules.every((m) => m.status === "created")) {
-        setInfraStatus({
-          hasError: false,
-        });
-      }
-
-      setTFModules([...tfModules]);
-
-      tfModules.forEach((val, index) => {
-        if (val?.status != "created") {
-          updateDesiredState(index, val);
-          setupInfraWebsocket(val.id + "", val, index);
-        }
-      });
-    });
-
-    return closeAllWebsockets;
-  }, []);
-
-  let sortedModules = tfModules.sort((a, b) =>
-    b.id < a.id ? -1 : b.id > a.id ? 1 : 0
-  );
-
-  return (
-    <>
-      <ProvisionerStatus modules={sortedModules} />
-    </>
-  );
-};

+ 641 - 0
dashboard/src/main/home/onboarding/steps/ProvisionResources/forms/StatusPage.tsx

@@ -0,0 +1,641 @@
+import ProvisionerStatus, {
+  TFModule,
+  TFResource,
+  TFResourceError,
+} from "components/ProvisionerStatus";
+import React, { useEffect, useMemo, useRef, useState } from "react";
+import api from "shared/api";
+import { NewWebsocketOptions, useWebsockets } from "shared/hooks/useWebsockets";
+
+type Props = {
+  setInfraStatus: (status: { hasError: boolean; description?: string }) => void;
+  project_id: number;
+  filter: string[];
+};
+
+type Infra = {
+  id: number;
+  created_at: string;
+  updated_at: string;
+  project_id: number;
+  kind: string;
+  status: string;
+  last_applied: any;
+};
+
+type Desired = {
+  addr: string;
+  errored:
+    | { errored_out: false }
+    | { errored_out: true; error_context: string };
+  implied_provider: string;
+  resource: string;
+  resource_name: string;
+  resource_type: string;
+};
+
+type InfraCurrentResponse = {
+  version: number;
+  terraform_version: string;
+  serial: number;
+  lineage: string;
+  outputs: any;
+  resources: {
+    instances: any[];
+    mode: string;
+    name: string;
+    provider: string;
+    type: string;
+  }[];
+};
+
+export const StatusPage = ({
+  filter: selectedFilters,
+  project_id,
+  setInfraStatus,
+}: Props) => {
+  const {
+    newWebsocket,
+    openWebsocket,
+    closeWebsocket,
+    closeAllWebsockets,
+  } = useWebsockets();
+
+  const {
+    tfModules,
+    initModule,
+    updateDesired,
+    updateModuleResources,
+    updateGlobalErrorsForModule,
+  } = useTFModules();
+
+  const { moduleStatuses } = useModuleChecker(tfModules);
+
+  const filterBySelectedInfras = (currentInfra: Infra) => {
+    if (!Array.isArray(selectedFilters) || !selectedFilters?.length) {
+      return true;
+    }
+
+    if (selectedFilters.includes(currentInfra.kind)) {
+      return true;
+    }
+    return false;
+  };
+
+  const getLatestInfras = (infras: Infra[]) => {
+    // Create a map with the relation infra.kind => infra
+    // This will allow us to keep only one infra per kind.
+    const infraMap = new Map<string, Infra>();
+
+    infras.forEach((infra) => {
+      // Get last infra from that kind, kind being gke, ecr, etc.
+      const latestSavedInfra = infraMap.get(infra.kind);
+
+      // If infra doesn't exists, it means its the first one appearing so we save it
+      if (!latestSavedInfra) {
+        infraMap.set(infra.kind, infra);
+        return;
+      }
+
+      // Check if the latest saved infra was recent than the one we're currently iterating
+      // If the current one iterating is newer, then we update the map!
+      if (
+        new Date(infra.created_at).getTime() >
+        new Date(latestSavedInfra.created_at).getTime()
+      ) {
+        infraMap.set(infra.kind, infra);
+        return;
+      }
+    });
+
+    // Get the array from the values of the array.
+    return Array.from(infraMap.values());
+  };
+
+  const getInfras = async () => {
+    try {
+      const res = await api.getInfra<Infra[]>(
+        "<token>",
+        {},
+        { project_id: project_id }
+      );
+      // Filter infras based on what we care only, usually on the onboarding we'll want only the ones
+      // currently being provisioned
+      const matchedInfras = res.data.filter(filterBySelectedInfras);
+
+      // Get latest infras for each kind of infra on the array.
+      const latestMatchedInfras = getLatestInfras(matchedInfras);
+
+      // Check if all infras are created then enable continue button
+      if (latestMatchedInfras.every((infra) => infra.status === "created")) {
+        setInfraStatus({
+          hasError: false,
+        });
+      }
+
+      // Init tf modules based on matched infras
+      latestMatchedInfras.forEach((infra) => {
+        // Init the module for the hook
+        initModule(infra);
+
+        // Update all the resources needed for the current infra
+        getDesiredState(infra.id);
+      });
+    } catch (error) {}
+  };
+
+  const getDesiredState = async (infra_id: number) => {
+    try {
+      const desired = await api
+        .getInfraDesired("<token>", {}, { project_id, infra_id })
+        .then((res) => res?.data);
+
+      updateDesired(infra_id, desired);
+      // Check if we have some modules already provisioned
+      await getProvisionedModules(infra_id);
+
+      // Connect to websocket that will provide live info of the provisioning for this infra
+      connectToLiveUpdateModule(infra_id);
+    } catch (error) {
+      console.error(error);
+      setTimeout(() => {
+        getDesiredState(infra_id);
+      }, 500);
+    }
+  };
+
+  const getProvisionedModules = async (infra_id: number) => {
+    try {
+      const current = await api
+        .getInfraCurrent<InfraCurrentResponse>(
+          "<token>",
+          {},
+          { project_id, infra_id }
+        )
+        .then((res) => res?.data);
+
+      const provisionedResources: TFResource[] = current?.resources?.map(
+        (resource: any) => {
+          return {
+            addr: `${resource?.type}.${resource?.name}`,
+            provisioned: true,
+            errored: {
+              errored_out: false,
+            },
+          } as TFResource;
+        }
+      );
+
+      updateModuleResources(infra_id, provisionedResources);
+    } catch (error) {
+      console.error(error);
+    }
+  };
+
+  const connectToLiveUpdateModule = (infra_id: number) => {
+    const websocketId = `${infra_id}`;
+    const apiPath = `/api/projects/${project_id}/infras/${infra_id}/logs`;
+
+    const wsConfig: NewWebsocketOptions = {
+      onopen: () => {
+        console.log(`connected to websocket for infra_id: ${websocketId}`);
+      },
+      onmessage: (evt: MessageEvent) => {
+        // parse the data
+        const parsedData = JSON.parse(evt.data);
+
+        const addedResources: TFResource[] = [];
+        const erroredResources: TFResource[] = [];
+        const globalErrors: TFResourceError[] = [];
+
+        for (const streamVal of parsedData) {
+          const streamValData = JSON.parse(streamVal?.Values?.data);
+
+          switch (streamValData?.type) {
+            case "apply_complete":
+              addedResources.push({
+                addr: streamValData?.hook?.resource?.addr,
+                provisioned: true,
+                errored: {
+                  errored_out: false,
+                },
+              });
+
+              break;
+            case "diagnostic":
+              if (streamValData["@level"] == "error") {
+                if (streamValData?.hook?.resource?.addr !== "") {
+                  erroredResources.push({
+                    addr: streamValData?.hook?.resource?.addr,
+                    provisioned: false,
+                    errored: {
+                      errored_out: true,
+                      error_context: streamValData["@message"],
+                    },
+                  });
+                } else {
+                  globalErrors.push({
+                    errored_out: true,
+                    error_context: streamValData["@message"],
+                  });
+                }
+              }
+            default:
+          }
+        }
+
+        updateModuleResources(infra_id, [
+          ...addedResources,
+          ...erroredResources,
+        ]);
+
+        updateGlobalErrorsForModule(infra_id, globalErrors);
+      },
+
+      onclose: () => {
+        console.log(`closing websocket for infra_id: ${websocketId}`);
+      },
+
+      onerror: (err: ErrorEvent) => {
+        console.log(err);
+        closeWebsocket(`${websocketId}`);
+      },
+    };
+
+    newWebsocket(websocketId, apiPath, wsConfig);
+    openWebsocket(websocketId);
+  };
+
+  useEffect(() => {
+    getInfras();
+    return () => {
+      closeAllWebsockets();
+    };
+  }, []);
+
+  useEffect(() => {
+    if (!tfModules?.length) {
+      setInfraStatus(null);
+      return;
+    }
+    const hasModuleWithError = tfModules.find(
+      (module) => module.status === "error"
+    );
+
+    const hasModuleInCreatingState = tfModules.find(
+      (module) => module.status === "creating"
+    );
+
+    const hasModuleWithTimerElapsed = moduleStatuses.find(
+      (module) => module.status === "timed_out"
+    );
+
+    if (hasModuleWithTimerElapsed) {
+      setInfraStatus({
+        hasError: true,
+        description:
+          "We weren't able to provision after 45 minutes, please try again.",
+      });
+      return;
+    }
+
+    if (hasModuleInCreatingState) {
+      setInfraStatus(null);
+      return;
+    }
+
+    if (!hasModuleInCreatingState && !hasModuleWithError) {
+      setInfraStatus({ hasError: false });
+      return;
+    }
+
+    if (!hasModuleInCreatingState && hasModuleWithError) {
+      setInfraStatus({ hasError: true });
+      return;
+    }
+  }, [tfModules, moduleStatuses]);
+
+  const sortedModules = tfModules.sort((a, b) =>
+    b.id < a.id ? -1 : b.id > a.id ? 1 : 0
+  );
+
+  return <ProvisionerStatus modules={sortedModules} />;
+};
+
+type TFModulesState = {
+  [infraId: number]: TFModule;
+};
+
+const useTFModules = () => {
+  // Use a ref to keep track of all the Terraform modules
+  const modules = useRef<TFModulesState>({});
+
+  // Use state to keep the reactive array of terraform modules
+  const [tfModules, setTfModules] = useState<TFModule[]>([]);
+
+  /**
+   * This will map out the ref containing all the terraform modules and return a sorted array.
+   */
+  const updateTFModules = (): void => {
+    if (typeof modules.current !== "object") {
+      setTfModules([]);
+    }
+
+    const sortedModules = Object.values(modules.current).sort((a, b) =>
+      b.id < a.id ? -1 : b.id > a.id ? 1 : 0
+    );
+    setTfModules(sortedModules);
+  };
+
+  /**
+   * Init a TFModule based on a Infra, this infra is usually more basic
+   * and doesn't contain all the resources that it actually needs.
+   * The initialized TFModule will be used to keep track if the infra
+   * changed from creating status to another one.
+   *
+   * @param infra Infra object used to initialize the terraform module used to track provisioning status
+   */
+  const initModule = (infra: Infra) => {
+    const module: TFModule = {
+      id: infra.id,
+      kind: infra.kind,
+      status: infra.status,
+      got_desired: false,
+      created_at: infra.created_at,
+      updated_at: infra.updated_at,
+    };
+    setModule(infra.id, module);
+  };
+
+  /**
+   * Add or replace if existed, this function will set the module into the ref
+   * and call the updateTFModules to update the array used to show the infras
+   *
+   * @param infraId Infra ID to be updated
+   * @param module New updated module
+   */
+  const setModule = (infraId: number, module: TFModule) => {
+    modules.current = {
+      ...modules.current,
+      [infraId]: module,
+    };
+    updateTFModules();
+  };
+
+  const getModule = (infraId: number) => {
+    return { ...modules.current[infraId] };
+  };
+
+  /**
+   * @param infraId Module to be updated
+   * @param desired All the desired resources that are going to be needed to complete provisioning
+   */
+  const updateDesired = (infraId: number, desired: Desired[]) => {
+    const selectedModule = getModule(infraId);
+
+    if (!Array.isArray(selectedModule?.resources)) {
+      selectedModule.resources = [];
+    }
+
+    selectedModule.resources = desired.map((d) => {
+      return {
+        addr: d.addr,
+        errored: d.errored,
+        provisioned: false,
+      };
+    });
+
+    setModule(infraId, selectedModule);
+  };
+
+  /**
+   * @param infraId Module to be updated
+   * @param updatedResources Updated resources array, this may contain one or more objects with some status updates.
+   */
+  const updateModuleResources = (
+    infraId: number,
+    updatedResources: TFResource[]
+  ) => {
+    const selectedModule = getModule(infraId);
+
+    const updatedModuleResources = selectedModule.resources.map((resource) => {
+      const correspondedResource: TFResource = updatedResources.find(
+        (updatedResource) => updatedResource.addr === resource.addr
+      );
+      if (!correspondedResource) {
+        return resource;
+      }
+      let errored = undefined;
+
+      if (correspondedResource?.errored) {
+        errored = {
+          ...(correspondedResource?.errored || {}),
+        };
+      }
+
+      return {
+        ...resource,
+        provisioned: correspondedResource.provisioned,
+        errored,
+      };
+    });
+
+    selectedModule.resources = updatedModuleResources;
+
+    const isModuleCreated =
+      selectedModule.resources.every((resource) => {
+        return resource.provisioned;
+      }) && !selectedModule.global_errors?.length;
+
+    const isModuleOnError =
+      selectedModule.resources.find((resource) => {
+        return resource.errored?.errored_out;
+      }) || selectedModule.global_errors?.length;
+
+    if (isModuleCreated) {
+      selectedModule.status = "created";
+    } else if (isModuleOnError) {
+      selectedModule.status = "error";
+    } else {
+      selectedModule.status = selectedModule.status;
+    }
+
+    setModule(infraId, selectedModule);
+  };
+
+  /**
+   * @param infraId Module to be updated
+   * @param globalErrors Errors that may not belong to a resource but appeared during provisioning
+   */
+  const updateGlobalErrorsForModule = (
+    infraId: number,
+    globalErrors: TFResourceError[]
+  ) => {
+    const module = getModule(infraId);
+
+    module.global_errors = [...(module.global_errors || []), ...globalErrors];
+    if (globalErrors.length) {
+      module.status = "error";
+    }
+    setModule(infraId, module);
+  };
+
+  return {
+    tfModules,
+    initModule,
+    updateDesired,
+    updateModuleResources,
+    updateGlobalErrorsForModule,
+  };
+};
+
+const useModuleChecker = (modules: TFModule[]) => {
+  const [timers, setTimers] = useState<{
+    [timerModuleId: number]: NodeJS.Timeout;
+  }>({});
+
+  const [moduleStatuses, setModuleStatus] = useState<{
+    [timerModuleId: number]: "timed_out" | "creating" | "success";
+  }>({});
+
+  const didModuleTimedOut = (infra: TFModule) => {
+    const last_updated = new Date(infra.updated_at).getTime();
+    const current_date = new Date().getTime();
+
+    let diff = (current_date - last_updated) / 1000 / 60;
+    const minutes_elapsed = Math.abs(Math.round(diff));
+
+    if (minutes_elapsed >= 45) {
+      return true;
+    }
+
+    return false;
+  };
+
+  const hasModuleAnyResourcesProvisionedOrErrored = (module: TFModule) => {
+    if (!Array.isArray(module.resources)) {
+      return false;
+    }
+
+    if (
+      module.resources.every(
+        (resource) => resource.provisioned || resource.errored?.errored_out
+      ) ||
+      module.global_errors.find((resourceError) => resourceError.errored_out)
+    ) {
+      return true;
+    }
+
+    return false;
+  };
+
+  const hasModuleBeenSuccessfullyProvisioned = (module: TFModule) => {
+    if (!Array.isArray(module.resources)) {
+      return false;
+    }
+
+    if (module.resources.every((resource) => resource.provisioned)) {
+      return true;
+    }
+
+    return false;
+  };
+
+  const setupTimeoutToCheckModuleTimeout = (module: TFModule) => {
+    const timer = setTimeout(() => {
+      if (!didModuleTimedOut(module)) {
+        return;
+      }
+
+      if (hasModuleBeenSuccessfullyProvisioned(module)) {
+        setModuleStatus((modulesStatus) => ({
+          ...modulesStatus,
+          [module.id]: "success",
+        }));
+        clearCheckerTimeout(module.id);
+        return;
+      }
+
+      if (!hasModuleAnyResourcesProvisionedOrErrored(module)) {
+        setModuleStatus((modulesStatus) => ({
+          ...modulesStatus,
+          [module.id]: "timed_out",
+        }));
+      } else {
+        setModuleStatus((modulesStatus) => ({
+          ...modulesStatus,
+          [module.id]: "creating",
+        }));
+      }
+      clearCheckerTimeout(module.id);
+    }, 1000);
+    return timer;
+  };
+
+  const clearCheckerTimeout = (moduleId: number) => {
+    const moduleInterval = timers[moduleId];
+    clearTimeout(moduleInterval);
+    setTimers((timers) => ({
+      ...timers,
+      [moduleId]: undefined,
+    }));
+  };
+
+  const clearCheckerTimers = () => {
+    if (typeof timers !== "object") {
+      return;
+    }
+
+    Object.entries(timers).forEach(([moduleId, intervalId]) => {
+      clearTimeout(intervalId);
+      setTimers((timers) => ({
+        ...timers,
+        [moduleId]: undefined,
+      }));
+    });
+  };
+
+  useEffect(() => {
+    modules.forEach((module) => {
+      if (timers[module.id]) {
+        clearTimeout(timers[module.id]);
+      }
+
+      if (
+        moduleStatuses[module.id] &&
+        moduleStatuses[module.id] !== "creating"
+      ) {
+        clearCheckerTimeout(module.id);
+        return;
+      }
+
+      const timerId = setupTimeoutToCheckModuleTimeout(module);
+
+      setTimers((timers) => ({
+        ...timers,
+        [module.id]: timerId,
+      }));
+    });
+
+    return () => {
+      clearCheckerTimers();
+    };
+  }, [modules, moduleStatuses]);
+
+  const moduleStatusesArray = useMemo(() => {
+    if (typeof moduleStatuses !== "object") {
+      return [];
+    }
+
+    return Object.entries(moduleStatuses).map(([moduleId, status]) => {
+      return {
+        id: moduleId,
+        status,
+      };
+    });
+  }, [moduleStatuses]);
+
+  return {
+    moduleStatuses: moduleStatusesArray,
+  };
+};

+ 0 - 1
dashboard/src/main/home/onboarding/steps/ProvisionResources/forms/_AWSProvisionerForm.tsx

@@ -10,7 +10,6 @@ import {
 import React, { useEffect, useState } from "react";
 import api from "shared/api";
 import { useSnapshot } from "valtio";
-import { SharedStatus } from "./SharedStatus";
 import Loading from "components/Loading";
 import Helper from "components/form-components/Helper";
 

+ 0 - 6
dashboard/src/main/home/onboarding/steps/ProvisionResources/forms/_DOProvisionerForm.tsx

@@ -1,10 +1,6 @@
 import Helper from "components/form-components/Helper";
 import InputRow from "components/form-components/InputRow";
 import SelectRow from "components/form-components/SelectRow";
-import ProvisionerStatus, {
-  TFModule,
-  TFResource,
-} from "components/ProvisionerStatus";
 import SaveButton from "components/SaveButton";
 import { OFState } from "main/home/onboarding/state";
 import { DOProvisionerConfig } from "main/home/onboarding/types";
@@ -12,8 +8,6 @@ import React, { useEffect, useState } from "react";
 import api from "shared/api";
 import styled from "styled-components";
 import { useSnapshot } from "valtio";
-import { useWebsockets } from "shared/hooks/useWebsockets";
-import { SharedStatus } from "./SharedStatus";
 import Loading from "components/Loading";
 
 const tierOptions = [

+ 1 - 3
dashboard/src/main/home/onboarding/steps/ProvisionResources/forms/_GCPProvisionerForm.tsx

@@ -13,7 +13,6 @@ import React, { useEffect, useState } from "react";
 import api from "shared/api";
 import styled from "styled-components";
 import { useSnapshot } from "valtio";
-import { SharedStatus } from "./SharedStatus";
 
 const regionOptions = [
   { value: "asia-east1", label: "asia-east1" },
@@ -231,8 +230,7 @@ export const CredentialsForm: React.FC<{
           {lastConnectedAccount?.gcp_sa_email || "n/a"}
         </Flex>
         <Right>
-          Connected at{" "}
-          {readableDate(lastConnectedAccount.created_at)}
+          Connected at {readableDate(lastConnectedAccount.created_at)}
         </Right>
       </PreviewRow>
       <Helper>

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

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

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

@@ -1132,6 +1132,63 @@ const getOnboardingRegistry = baseApi<
     `/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)
 export default {
   checkAuth,
@@ -1249,4 +1306,10 @@ export default {
   saveOnboardingState,
   getOnboardingInfra,
   getOnboardingRegistry,
+  detectPorterAgent,
+  installPorterAgent,
+  getKubeEvents,
+  getKubeEvent,
+  getLogBuckets,
+  getLogBucketLogs,
 };

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

@@ -331,3 +331,19 @@ export interface UsageData {
   exceeds: boolean;
   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[];
+};

+ 4 - 2
docker/Dockerfile

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

+ 4 - 2
ee/docker/ee.Dockerfile

@@ -38,12 +38,14 @@ RUN --mount=type=cache,target=/root/.cache/go-build \
 
 # Webpack build environment
 # -------------------------
-FROM node:lts as build-webpack
+FROM node:16 as build-webpack
 WORKDIR /webpack
 
 COPY ./dashboard ./
 
-RUN npm i
+RUN npm install -g npm@8.1
+
+RUN npm i --legacy-peer-deps
 
 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
 }
 
+var ErrNotFound = fmt.Errorf("Not found")
+
 func (c *Client) getRequest(path string, dst interface{}) error {
 	req, err := http.NewRequest(
 		"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())
 		}
 
+		if res.StatusCode == http.StatusNotFound {
+			return ErrNotFound
+		}
+
 		return fmt.Errorf("request failed with status code %d: %s\n", res.StatusCode, string(resBytes))
 	}
 

+ 20 - 26
internal/integrations/buildpacks/go.go

@@ -31,11 +31,6 @@ func (runtime *goRuntime) detectMod(results chan struct {
 			string
 			bool
 		}{mod, true}
-	} else {
-		results <- struct {
-			string
-			bool
-		}{mod, false}
 	}
 	runtime.wg.Done()
 }
@@ -62,11 +57,6 @@ func (runtime *goRuntime) detectDep(results chan struct {
 			string
 			bool
 		}{dep, true}
-	} else {
-		results <- struct {
-			string
-			bool
-		}{dep, false}
 	}
 	runtime.wg.Done()
 }
@@ -76,7 +66,8 @@ func (runtime *goRuntime) Detect(
 	directoryContent []*github.RepositoryContent,
 	owner, name, path string,
 	repoContentOptions github.RepositoryContentGetOptions,
-) *RuntimeResponse {
+	paketo, heroku *BuilderInfo,
+) error {
 	results := make(chan struct {
 		string
 		bool
@@ -91,26 +82,29 @@ func (runtime *goRuntime) Detect(
 	runtime.wg.Wait()
 	close(results)
 
+	paketoBuildpackInfo := BuildpackInfo{
+		Name:      "Go",
+		Buildpack: "paketobuildpacks/go",
+	}
+	herokuBuildpackInfo := BuildpackInfo{
+		Name:      "Go",
+		Buildpack: "heroku/go",
+	}
+
+	if len(results) == 0 {
+		fmt.Printf("No Go runtime detected for %s/%s\n", owner, name)
+		paketo.Others = append(paketo.Others, paketoBuildpackInfo)
+		heroku.Others = append(heroku.Others, herokuBuildpackInfo)
+		return nil
+	}
+
 	detected := make(map[string]bool)
 	for result := range results {
 		detected[result.string] = result.bool
 	}
 
-	// TODO: how to access config values for Go projects
-	if detected[mod] {
-		fmt.Printf("Go mod runtime detected for %s/%s\n", owner, name)
-		return &RuntimeResponse{
-			Name:    "Go",
-			Runtime: mod,
-		}
-	} else if detected[dep] {
-		fmt.Printf("Go dep runtime detected for %s/%s\n", owner, name)
-		return &RuntimeResponse{
-			Name:    "Go",
-			Runtime: dep,
-		}
-	}
+	paketo.Detected = append(paketo.Detected, paketoBuildpackInfo)
+	heroku.Detected = append(heroku.Detected, herokuBuildpackInfo)
 
-	fmt.Printf("No Go runtime detected for %s/%s\n", owner, name)
 	return nil
 }

+ 68 - 169
internal/integrations/buildpacks/nodejs.go

@@ -4,13 +4,11 @@ import (
 	"context"
 	"encoding/json"
 	"fmt"
-	"os"
 	"strings"
 	"sync"
 
 	"github.com/Masterminds/semver/v3"
 	"github.com/google/go-github/github"
-	"github.com/pelletier/go-toml"
 )
 
 var (
@@ -23,108 +21,13 @@ var (
 )
 
 type nodejsRuntime struct {
-	wg    sync.WaitGroup
-	packs map[string]*BuildpackInfo
+	wg sync.WaitGroup
 }
 
 func NewNodeRuntime() Runtime {
 	return &nodejsRuntime{}
 }
 
-// FIXME: should be called once at the top-level somewhere in the backend
-func populateNodePacks(client *github.Client) map[string]*BuildpackInfo {
-	packs := make(map[string]*BuildpackInfo)
-
-	repoRelease, _, err := client.Repositories.GetLatestRelease(context.Background(), "paketo-buildpacks", "nodejs")
-	if err != nil {
-		fmt.Printf("Error fetching latest release for paketo-buildpacks/nodejs: %v\n", err)
-		return nil
-	}
-	fileContent, _, _, err := client.Repositories.GetContents(
-		context.Background(), "paketo-buildpacks", "nodejs", "buildpack.toml",
-		&github.RepositoryContentGetOptions{
-			Ref: *repoRelease.TagName,
-		},
-	)
-	if err != nil {
-		fmt.Printf("Error fetching contents of buildpack.toml for paketo-buildpacks/nodejs: %v\n", err)
-		return nil
-	}
-
-	data, err := fileContent.GetContent()
-	if err != nil {
-		fmt.Printf("Error calling GetContent() on buildpack.toml for paketo-buildpacks/nodejs: %v\n", err)
-		return nil
-	}
-
-	buildpackToml, err := toml.Load(data)
-	if err != nil {
-		fmt.Printf("Error while reading buildpack.toml from paketo-buildpacks/nodejs: %v\n", err)
-		os.Exit(1)
-	}
-	order := buildpackToml.Get("order").([]*toml.Tree)
-
-	// yarn
-	packs[yarn] = newBuildpackInfo()
-	yarnGroup := order[0].GetArray("group").([]*toml.Tree)
-	for i := 0; i < len(yarnGroup); i++ {
-		packs[yarn].addPack(
-			buildpackOrderGroupInfo{
-				ID:       yarnGroup[i].Get("id").(string),
-				Optional: yarnGroup[i].GetDefault("optional", false).(bool),
-				Version:  yarnGroup[i].Get("version").(string),
-			},
-		)
-	}
-	packs[yarn].addEnvVar("SSL_CERT_DIR", "")
-	packs[yarn].addEnvVar("SSL_CERT_FILE", "")
-	packs[yarn].addEnvVar("BP_NODE_OPTIMIZE_MEMORY", "")
-	packs[yarn].addEnvVar("BP_NODE_PROJECT_PATH", "")
-	packs[yarn].addEnvVar("BP_NODE_VERSION", "")
-	packs[yarn].addEnvVar("BP_NODE_RUN_SCRIPTS", "")
-
-	// npm
-	packs[npm] = newBuildpackInfo()
-	npmGroup := order[1].GetArray("group").([]*toml.Tree)
-	for i := 0; i < len(npmGroup); i++ {
-		packs[npm].addPack(
-			buildpackOrderGroupInfo{
-				ID:       npmGroup[i].Get("id").(string),
-				Optional: npmGroup[i].GetDefault("optional", false).(bool),
-				Version:  npmGroup[i].Get("version").(string),
-			},
-		)
-	}
-	packs[npm].addEnvVar("SSL_CERT_DIR", "")
-	packs[npm].addEnvVar("SSL_CERT_FILE", "")
-	packs[npm].addEnvVar("BP_NODE_OPTIMIZE_MEMORY", "")
-	packs[npm].addEnvVar("BP_NODE_PROJECT_PATH", "")
-	packs[npm].addEnvVar("BP_NODE_VERSION", "")
-	packs[npm].addEnvVar("BP_NODE_RUN_SCRIPTS", "")
-
-	// no package manager
-	packs[standalone] = newBuildpackInfo()
-	standaloneGroup := order[2].GetArray("group").([]*toml.Tree)
-	for i := 0; i < len(standaloneGroup); i++ {
-		packs[standalone].addPack(
-			buildpackOrderGroupInfo{
-				ID:       standaloneGroup[i].Get("id").(string),
-				Optional: standaloneGroup[i].GetDefault("optional", false).(bool),
-				Version:  standaloneGroup[i].Get("version").(string),
-			},
-		)
-	}
-	packs[standalone].addEnvVar("SSL_CERT_DIR", "")
-	packs[standalone].addEnvVar("SSL_CERT_FILE", "")
-	packs[standalone].addEnvVar("BP_NODE_OPTIMIZE_MEMORY", "")
-	packs[standalone].addEnvVar("BP_NODE_PROJECT_PATH", "")
-	packs[standalone].addEnvVar("BP_NODE_VERSION", "")
-	packs[standalone].addEnvVar("BP_LAUNCHPOINT", "")
-	packs[standalone].addEnvVar("BP_LIVE_RELOAD_ENABLED", "")
-
-	return packs
-}
-
 func (runtime *nodejsRuntime) detectYarn(results chan struct {
 	string
 	bool
@@ -147,11 +50,6 @@ func (runtime *nodejsRuntime) detectYarn(results chan struct {
 			string
 			bool
 		}{yarn, true}
-	} else {
-		results <- struct {
-			string
-			bool
-		}{yarn, false}
 	}
 	runtime.wg.Done()
 }
@@ -173,11 +71,6 @@ func (runtime *nodejsRuntime) detectNPM(results chan struct {
 			string
 			bool
 		}{npm, true}
-	} else {
-		results <- struct {
-			string
-			bool
-		}{npm, false}
 	}
 	runtime.wg.Done()
 }
@@ -199,11 +92,6 @@ func (runtime *nodejsRuntime) detectStandalone(results chan struct {
 			string
 			bool
 		}{standalone, true}
-	} else {
-		results <- struct {
-			string
-			bool
-		}{standalone, false}
 	}
 	runtime.wg.Done()
 }
@@ -274,13 +162,12 @@ func (runtime *nodejsRuntime) Detect(
 	directoryContent []*github.RepositoryContent,
 	owner, name, path string,
 	repoContentOptions github.RepositoryContentGetOptions,
-) *RuntimeResponse {
-	runtime.packs = populateNodePacks(client)
-
+	paketo, heroku *BuilderInfo,
+) error {
 	results := make(chan struct {
 		string
 		bool
-	}, 3)
+	})
 
 	fmt.Printf("Starting detection for a NodeJS runtime for %s/%s\n", owner, name)
 	runtime.wg.Add(3)
@@ -293,12 +180,36 @@ func (runtime *nodejsRuntime) Detect(
 	runtime.wg.Wait()
 	close(results)
 
-	detected := make(map[string]bool)
+	paketoBuildpackInfo := BuildpackInfo{
+		Name:      "NodeJS",
+		Buildpack: "paketobuildpacks/nodejs",
+	}
+	herokuBuildpackInfo := BuildpackInfo{
+		Name:      "NodeJS",
+		Buildpack: "heroku/nodejs",
+	}
+
+	if len(results) == 0 {
+		fmt.Printf("No NodeJS runtime detected for %s/%s\n", owner, name)
+		paketo.Others = append(paketo.Others, paketoBuildpackInfo)
+		heroku.Others = append(heroku.Others, herokuBuildpackInfo)
+		return nil
+	}
+
+	foundYarn := false
+	foundNPM := false
+	foundStandalone := false
 	for result := range results {
-		detected[result.string] = result.bool
+		if result.string == yarn {
+			foundYarn = true
+		} else if result.string == npm {
+			foundNPM = true
+		} else if result.string == standalone {
+			foundStandalone = true
+		}
 	}
 
-	if detected[yarn] || detected[npm] {
+	if foundYarn || foundNPM {
 		// it is safe to assume that the project contains a package.json
 		fmt.Println("package.json file detected")
 		fileContent, _, _, err := client.Repositories.GetContents(
@@ -309,8 +220,9 @@ func (runtime *nodejsRuntime) Detect(
 			&repoContentOptions,
 		)
 		if err != nil {
-			fmt.Printf("Error fetching contents of package.json: %v\n", err)
-			return nil
+			paketo.Others = append(paketo.Others, paketoBuildpackInfo)
+			heroku.Others = append(heroku.Others, herokuBuildpackInfo)
+			return fmt.Errorf("error fetching contents of package.json: %v", err)
 		}
 		var packageJSON struct {
 			Scripts map[string]string `json:"scripts"`
@@ -321,13 +233,15 @@ func (runtime *nodejsRuntime) Detect(
 
 		data, err := fileContent.GetContent()
 		if err != nil {
-			fmt.Printf("Error calling GetContent() on package.json: %v\n", err)
-			return nil
+			paketo.Others = append(paketo.Others, paketoBuildpackInfo)
+			heroku.Others = append(heroku.Others, herokuBuildpackInfo)
+			return fmt.Errorf("error calling GetContent() on package.json: %v", err)
 		}
 		err = json.NewDecoder(strings.NewReader(data)).Decode(&packageJSON)
 		if err != nil {
-			fmt.Printf("Error decoding package.json contents to struct: %v\n", err)
-			return nil
+			paketo.Others = append(paketo.Others, paketoBuildpackInfo)
+			heroku.Others = append(heroku.Others, herokuBuildpackInfo)
+			return fmt.Errorf("error decoding package.json contents to struct: %v", err)
 		}
 
 		if packageJSON.Engines.Node == "" {
@@ -353,18 +267,21 @@ func (runtime *nodejsRuntime) Detect(
 					&repoContentOptions,
 				)
 				if err != nil {
-					fmt.Printf("Error fetching contents of .nvmrc: %v\n", err)
-					return nil
+					paketo.Others = append(paketo.Others, paketoBuildpackInfo)
+					heroku.Others = append(heroku.Others, herokuBuildpackInfo)
+					return fmt.Errorf("error fetching contents of .nvmrc: %v", err)
 				}
 				data, err = fileContent.GetContent()
 				if err != nil {
-					fmt.Printf("Error calling GetContent() on .nvmrc: %v\n", err)
-					return nil
+					paketo.Others = append(paketo.Others, paketoBuildpackInfo)
+					heroku.Others = append(heroku.Others, herokuBuildpackInfo)
+					return fmt.Errorf("error calling GetContent() on .nvmrc: %v", err)
 				}
 				nvmrcVersion, err := validateNvmrc(data)
 				if err != nil {
-					fmt.Printf("Error validating .nvmrc: %v\n", err)
-					return nil
+					paketo.Others = append(paketo.Others, paketoBuildpackInfo)
+					heroku.Others = append(heroku.Others, herokuBuildpackInfo)
+					return fmt.Errorf("error validating .nvmrc: %v", err)
 				}
 				nvmrcVersion = formatNvmrcContent(nvmrcVersion)
 
@@ -383,18 +300,21 @@ func (runtime *nodejsRuntime) Detect(
 					&repoContentOptions,
 				)
 				if err != nil {
-					fmt.Printf("Error fetching contents of .node-version: %v\n", err)
-					return nil
+					paketo.Others = append(paketo.Others, paketoBuildpackInfo)
+					heroku.Others = append(heroku.Others, herokuBuildpackInfo)
+					return fmt.Errorf("error fetching contents of .node-version: %v", err)
 				}
 				data, err = fileContent.GetContent()
 				if err != nil {
-					fmt.Printf("Error calling GetContent() on .node-version: %v\n", err)
-					return nil
+					paketo.Others = append(paketo.Others, paketoBuildpackInfo)
+					heroku.Others = append(heroku.Others, herokuBuildpackInfo)
+					return fmt.Errorf("error calling GetContent() on .node-version: %v", err)
 				}
 				nodeVersion, err := validateNodeVersion(data)
 				if err != nil {
-					fmt.Printf("Error validating .node-version: %v\n", err)
-					return nil
+					paketo.Others = append(paketo.Others, paketoBuildpackInfo)
+					heroku.Others = append(heroku.Others, herokuBuildpackInfo)
+					return fmt.Errorf("error validating .node-version: %v", err)
 				}
 				if nodeVersion != "" {
 					packageJSON.Engines.Node = nodeVersion
@@ -407,38 +327,17 @@ func (runtime *nodejsRuntime) Detect(
 			packageJSON.Engines.Node = "16.*.*"
 		}
 
-		if detected[yarn] {
-			fmt.Printf("NodeJS yarn runtime detected for %s/%s\n", owner, name)
-			return &RuntimeResponse{
-				Name:       "Node.js",
-				Buildpacks: runtime.packs[yarn],
-				Runtime:    yarn,
-				Config: map[string]interface{}{
-					"scripts":     packageJSON.Scripts,
-					"node_engine": packageJSON.Engines.Node,
-				},
-			}
-		} else {
-			fmt.Printf("NodeJS npm runtime detected for %s/%s\n", owner, name)
-			return &RuntimeResponse{
-				Name:       "Node.js",
-				Buildpacks: runtime.packs[npm],
-				Runtime:    npm,
-				Config: map[string]interface{}{
-					"scripts":     packageJSON.Scripts,
-					"node_engine": packageJSON.Engines.Node,
-				},
-			}
-		}
-	} else if detected[standalone] {
-		fmt.Printf("NodeJS standalone runtime detected for %s/%s\n", owner, name)
-		return &RuntimeResponse{
-			Name:       "Node.js",
-			Buildpacks: runtime.packs[standalone],
-			Runtime:    standalone,
-		}
+		paketoBuildpackInfo.Config["scripts"] = packageJSON.Scripts
+		paketoBuildpackInfo.Config["node_engine"] = packageJSON.Engines.Node
+		paketo.Detected = append(paketo.Detected, paketoBuildpackInfo)
+
+		herokuBuildpackInfo.Config["scripts"] = packageJSON.Scripts
+		herokuBuildpackInfo.Config["node_engine"] = packageJSON.Engines.Node
+		heroku.Detected = append(heroku.Detected, herokuBuildpackInfo)
+	} else if foundStandalone {
+		paketo.Detected = append(paketo.Detected, paketoBuildpackInfo)
+		heroku.Detected = append(heroku.Detected, herokuBuildpackInfo)
 	}
 
-	fmt.Printf("No NodeJS runtime detected for %s/%s\n", owner, name)
 	return nil
 }

+ 19 - 150
internal/integrations/buildpacks/python.go

@@ -1,107 +1,15 @@
 package buildpacks
 
 import (
-	"context"
 	"fmt"
-	"os"
 	"strings"
 	"sync"
 
 	"github.com/google/go-github/github"
-	"github.com/pelletier/go-toml"
 )
 
 type pythonRuntime struct {
-	wg    sync.WaitGroup
-	packs map[string]*BuildpackInfo
-}
-
-// FIXME: should be called once at the top-level somewhere in the backend
-func populatePythonPacks(client *github.Client) map[string]*BuildpackInfo {
-	packs := make(map[string]*BuildpackInfo)
-
-	repoRelease, _, err := client.Repositories.GetLatestRelease(context.Background(), "paketo-buildpacks", "python")
-	if err != nil {
-		fmt.Printf("Error fetching latest release for paketo-buildpacks/python: %v\n", err)
-		return nil
-	}
-	fileContent, _, _, err := client.Repositories.GetContents(
-		context.Background(), "paketo-buildpacks", "python", "buildpack.toml",
-		&github.RepositoryContentGetOptions{
-			Ref: *repoRelease.TagName,
-		},
-	)
-	if err != nil {
-		fmt.Printf("Error fetching contents of buildpack.toml for paketo-buildpacks/python: %v\n", err)
-		return nil
-	}
-
-	data, err := fileContent.GetContent()
-	if err != nil {
-		fmt.Printf("Error calling GetContent() on buildpack.toml for paketo-buildpacks/python: %v\n", err)
-		return nil
-	}
-
-	buildpackToml, err := toml.Load(data)
-	if err != nil {
-		fmt.Printf("Error while reading buildpack.toml from paketo-buildpacks/python: %v\n", err)
-		os.Exit(1)
-	}
-	order := buildpackToml.Get("order").([]*toml.Tree)
-
-	// pipenv
-	packs[pipenv] = newBuildpackInfo()
-	pipenvGroup := order[0].GetArray("group").([]*toml.Tree)
-	for i := 0; i < len(pipenvGroup); i++ {
-		packs[pipenv].addPack(
-			buildpackOrderGroupInfo{
-				ID:       pipenvGroup[i].Get("id").(string),
-				Optional: pipenvGroup[i].GetDefault("optional", false).(bool),
-				Version:  pipenvGroup[i].Get("version").(string),
-			},
-		)
-	}
-
-	// pip
-	packs[pip] = newBuildpackInfo()
-	pipGroup := order[1].GetArray("group").([]*toml.Tree)
-	for i := 0; i < len(pipGroup); i++ {
-		packs[pip].addPack(
-			buildpackOrderGroupInfo{
-				ID:       pipGroup[i].Get("id").(string),
-				Optional: pipGroup[i].GetDefault("optional", false).(bool),
-				Version:  pipGroup[i].Get("version").(string),
-			},
-		)
-	}
-
-	// conda
-	packs[conda] = newBuildpackInfo()
-	condaGroup := order[2].GetArray("group").([]*toml.Tree)
-	for i := 0; i < len(condaGroup); i++ {
-		packs[pip].addPack(
-			buildpackOrderGroupInfo{
-				ID:       condaGroup[i].Get("id").(string),
-				Optional: condaGroup[i].GetDefault("optional", false).(bool),
-				Version:  condaGroup[i].Get("version").(string),
-			},
-		)
-	}
-
-	// no package manager
-	packs[standalone] = newBuildpackInfo()
-	standaloneGroup := order[3].GetArray("group").([]*toml.Tree)
-	for i := 0; i < len(standaloneGroup); i++ {
-		packs[standalone].addPack(
-			buildpackOrderGroupInfo{
-				ID:       standaloneGroup[i].Get("id").(string),
-				Optional: standaloneGroup[i].GetDefault("optional", false).(bool),
-				Version:  standaloneGroup[i].Get("version").(string),
-			},
-		)
-	}
-
-	return packs
+	wg sync.WaitGroup
 }
 
 func NewPythonRuntime() Runtime {
@@ -130,11 +38,6 @@ func (runtime *pythonRuntime) detectPipenv(results chan struct {
 			string
 			bool
 		}{pipenv, true}
-	} else {
-		results <- struct {
-			string
-			bool
-		}{pipenv, false}
 	}
 	runtime.wg.Done()
 }
@@ -155,11 +58,6 @@ func (runtime *pythonRuntime) detectPip(results chan struct {
 			string
 			bool
 		}{pip, true}
-	} else {
-		results <- struct {
-			string
-			bool
-		}{pip, false}
 	}
 	runtime.wg.Done()
 }
@@ -185,11 +83,6 @@ func (runtime *pythonRuntime) detectConda(results chan struct {
 			string
 			bool
 		}{conda, true}
-	} else {
-		results <- struct {
-			string
-			bool
-		}{conda, false}
 	}
 	runtime.wg.Done()
 }
@@ -211,11 +104,6 @@ func (runtime *pythonRuntime) detectStandalone(results chan struct {
 			string
 			bool
 		}{standalone, true}
-	} else {
-		results <- struct {
-			string
-			bool
-		}{standalone, false}
 	}
 	runtime.wg.Done()
 }
@@ -225,13 +113,12 @@ func (runtime *pythonRuntime) Detect(
 	directoryContent []*github.RepositoryContent,
 	owner, name, path string,
 	repoContentOptions github.RepositoryContentGetOptions,
-) *RuntimeResponse {
-	runtime.packs = populatePythonPacks(client)
-
+	paketo, heroku *BuilderInfo,
+) error {
 	results := make(chan struct {
 		string
 		bool
-	}, 4)
+	})
 
 	fmt.Printf("Starting detection for a Python runtime for %s/%s\n", owner, name)
 	runtime.wg.Add(4)
@@ -246,42 +133,24 @@ func (runtime *pythonRuntime) Detect(
 	runtime.wg.Wait()
 	close(results)
 
-	detected := make(map[string]bool)
-	for result := range results {
-		detected[result.string] = result.bool
+	paketoBuildpackInfo := BuildpackInfo{
+		Name:      "Python",
+		Buildpack: "paketobuildpacks/python",
+	}
+	herokuBuildpackInfo := BuildpackInfo{
+		Name:      "Python",
+		Buildpack: "heroku/python",
 	}
 
-	// TODO: how to access config values for Python projects
-	if detected[pipenv] {
-		fmt.Printf("Python pipenv runtime detected for %s/%s\n", owner, name)
-		return &RuntimeResponse{
-			Name:       "Python",
-			Runtime:    pipenv,
-			Buildpacks: runtime.packs[pipenv],
-		}
-	} else if detected[pip] {
-		fmt.Printf("Python pip runtime detected for %s/%s\n", owner, name)
-		return &RuntimeResponse{
-			Name:       "Python",
-			Runtime:    pip,
-			Buildpacks: runtime.packs[pip],
-		}
-	} else if detected[conda] {
-		fmt.Printf("Python conda runtime detected for %s/%s\n", owner, name)
-		return &RuntimeResponse{
-			Name:       "Python",
-			Runtime:    conda,
-			Buildpacks: runtime.packs[conda],
-		}
-	} else if detected[standalone] {
-		fmt.Printf("Python standalone runtime detected for %s/%s\n", owner, name)
-		return &RuntimeResponse{
-			Name:       "Python",
-			Runtime:    standalone,
-			Buildpacks: runtime.packs[standalone],
-		}
+	if len(results) == 0 {
+		fmt.Printf("No Python runtime detected for %s/%s\n", owner, name)
+		paketo.Others = append(paketo.Others, paketoBuildpackInfo)
+		heroku.Others = append(heroku.Others, herokuBuildpackInfo)
+		return nil
 	}
 
-	fmt.Printf("No Python runtime detected for %s/%s\n", owner, name)
+	paketo.Detected = append(paketo.Detected, paketoBuildpackInfo)
+	heroku.Detected = append(heroku.Detected, herokuBuildpackInfo)
+
 	return nil
 }

+ 26 - 208
internal/integrations/buildpacks/ruby.go

@@ -4,132 +4,15 @@ import (
 	"bufio"
 	"context"
 	"fmt"
-	"os"
 	"regexp"
 	"strings"
 	"sync"
 
 	"github.com/google/go-github/github"
-	"github.com/pelletier/go-toml"
 )
 
 type rubyRuntime struct {
-	wg    sync.WaitGroup
-	packs map[string]*BuildpackInfo
-}
-
-// FIXME: should be called once at the top-level somewhere in the backend
-func populateRubyPacks(client *github.Client) map[string]*BuildpackInfo {
-	packs := make(map[string]*BuildpackInfo)
-
-	repoRelease, _, err := client.Repositories.GetLatestRelease(context.Background(), "paketo-buildpacks", "ruby")
-	if err != nil {
-		fmt.Printf("Error fetching latest release for paketo-buildpacks/ruby: %v\n", err)
-		return nil
-	}
-	fileContent, _, _, err := client.Repositories.GetContents(
-		context.Background(), "paketo-buildpacks", "ruby", "buildpack.toml",
-		&github.RepositoryContentGetOptions{
-			Ref: *repoRelease.TagName,
-		},
-	)
-	if err != nil {
-		fmt.Printf("Error fetching contents of buildpack.toml for paketo-buildpacks/ruby: %v\n", err)
-		return nil
-	}
-
-	data, err := fileContent.GetContent()
-	if err != nil {
-		fmt.Printf("Error calling GetContent() on buildpack.toml for paketo-buildpacks/ruby: %v\n", err)
-		return nil
-	}
-
-	buildpackToml, err := toml.Load(data)
-	if err != nil {
-		fmt.Printf("Error while reading buildpack.toml from paketo-buildpacks/ruby: %v\n", err)
-		os.Exit(1)
-	}
-	order := buildpackToml.Get("order").([]*toml.Tree)
-
-	// puma
-	packs[puma] = newBuildpackInfo()
-	pumaGroup := order[0].GetArray("group").([]*toml.Tree)
-	for i := 0; i < len(pumaGroup); i++ {
-		packs[puma].addPack(
-			buildpackOrderGroupInfo{
-				ID:       pumaGroup[i].Get("id").(string),
-				Optional: pumaGroup[i].GetDefault("optional", false).(bool),
-				Version:  pumaGroup[i].Get("version").(string),
-			},
-		)
-	}
-
-	// thin
-	packs[thin] = newBuildpackInfo()
-	thinGroup := order[1].GetArray("group").([]*toml.Tree)
-	for i := 0; i < len(thinGroup); i++ {
-		packs[thin].addPack(
-			buildpackOrderGroupInfo{
-				ID:       thinGroup[i].Get("id").(string),
-				Optional: thinGroup[i].GetDefault("optional", false).(bool),
-				Version:  thinGroup[i].Get("version").(string),
-			},
-		)
-	}
-
-	// unicorn
-	packs[unicorn] = newBuildpackInfo()
-	unicornGroup := order[2].GetArray("group").([]*toml.Tree)
-	for i := 0; i < len(unicornGroup); i++ {
-		packs[unicorn].addPack(
-			buildpackOrderGroupInfo{
-				ID:       unicornGroup[i].Get("id").(string),
-				Optional: unicornGroup[i].GetDefault("optional", false).(bool),
-				Version:  unicornGroup[i].Get("version").(string),
-			},
-		)
-	}
-
-	// passenger
-	packs[passenger] = newBuildpackInfo()
-	passengerGroup := order[3].GetArray("group").([]*toml.Tree)
-	for i := 0; i < len(passengerGroup); i++ {
-		packs[passenger].addPack(
-			buildpackOrderGroupInfo{
-				ID:       passengerGroup[i].Get("id").(string),
-				Optional: passengerGroup[i].GetDefault("optional", false).(bool),
-				Version:  passengerGroup[i].Get("version").(string),
-			},
-		)
-	}
-
-	// rackup
-	packs[rackup] = newBuildpackInfo()
-	rackupGroup := order[4].GetArray("group").([]*toml.Tree)
-	for i := 0; i < len(rackupGroup); i++ {
-		packs[rackup].addPack(
-			buildpackOrderGroupInfo{
-				ID:       rackupGroup[i].Get("id").(string),
-				Optional: rackupGroup[i].GetDefault("optional", false).(bool),
-				Version:  rackupGroup[i].Get("version").(string),
-			},
-		)
-	}
-
-	// rake
-	packs[rake] = newBuildpackInfo()
-	rakeGroup := order[5].GetArray("group").([]*toml.Tree)
-	for i := 0; i < len(rakeGroup); i++ {
-		packs[rake].addPack(
-			buildpackOrderGroupInfo{
-				ID:       rakeGroup[i].Get("id").(string),
-				Optional: rakeGroup[i].GetDefault("optional", false).(bool),
-				Version:  rakeGroup[i].Get("version").(string),
-			},
-		)
-	}
-
-	return packs
+	wg sync.WaitGroup
 }
 
 func NewRubyRuntime() Runtime {
@@ -156,11 +39,6 @@ func (runtime *rubyRuntime) detectPuma(gemfileContent string, results chan struc
 			string
 			bool
 		}{puma, true}
-	} else {
-		results <- struct {
-			string
-			bool
-		}{puma, false}
 	}
 	runtime.wg.Done()
 }
@@ -185,11 +63,6 @@ func (runtime *rubyRuntime) detectThin(gemfileContent string, results chan struc
 			string
 			bool
 		}{thin, true}
-	} else {
-		results <- struct {
-			string
-			bool
-		}{thin, false}
 	}
 	runtime.wg.Done()
 }
@@ -214,11 +87,6 @@ func (runtime *rubyRuntime) detectUnicorn(gemfileContent string, results chan st
 			string
 			bool
 		}{unicorn, true}
-	} else {
-		results <- struct {
-			string
-			bool
-		}{unicorn, false}
 	}
 	runtime.wg.Done()
 }
@@ -243,11 +111,6 @@ func (runtime *rubyRuntime) detectPassenger(gemfileContent string, results chan
 			string
 			bool
 		}{passenger, true}
-	} else {
-		results <- struct {
-			string
-			bool
-		}{passenger, false}
 	}
 	runtime.wg.Done()
 }
@@ -290,11 +153,6 @@ func (runtime *rubyRuntime) detectRackup(
 			string
 			bool
 		}{rackup, true}
-	} else {
-		results <- struct {
-			string
-			bool
-		}{rackup, false}
 	}
 	runtime.wg.Done()
 }
@@ -319,11 +177,6 @@ func (runtime *rubyRuntime) detectRake(gemfileContent string, results chan struc
 			string
 			bool
 		}{rake, true}
-	} else {
-		results <- struct {
-			string
-			bool
-		}{rake, false}
 	}
 	runtime.wg.Done()
 }
@@ -333,9 +186,8 @@ func (runtime *rubyRuntime) Detect(
 	directoryContent []*github.RepositoryContent,
 	owner, name, path string,
 	repoContentOptions github.RepositoryContentGetOptions,
-) *RuntimeResponse {
-	runtime.packs = populateRubyPacks(client)
-
+	paketo, heroku *BuilderInfo,
+) error {
 	gemfileFound := false
 	gemfileLockFound := false
 	configRuFound := false
@@ -353,20 +205,33 @@ func (runtime *rubyRuntime) Detect(
 		}
 	}
 
+	paketoBuildpackInfo := BuildpackInfo{
+		Name:      "Ruby",
+		Buildpack: "paketobuildpacks/ruby",
+	}
+	herokuBuildpackInfo := BuildpackInfo{
+		Name:      "Ruby",
+		Buildpack: "heroku/ruby",
+	}
+
 	if !gemfileFound {
 		fmt.Printf("No Ruby runtime detected for %s/%s\n", owner, name)
+		paketo.Others = append(paketo.Others, paketoBuildpackInfo)
+		heroku.Others = append(heroku.Others, herokuBuildpackInfo)
 		return nil
 	}
 
 	fileContent, _, _, err := client.Repositories.GetContents(context.Background(), owner, name, "Gemfile", &repoContentOptions)
 	if err != nil {
-		fmt.Printf("Error fetching contents of Gemfile for %s/%s: %v\n", owner, name, err)
-		return nil
+		paketo.Others = append(paketo.Others, paketoBuildpackInfo)
+		heroku.Others = append(heroku.Others, herokuBuildpackInfo)
+		return fmt.Errorf("error fetching contents of Gemfile for %s/%s: %v", owner, name, err)
 	}
 	gemfileContent, err := fileContent.GetContent()
 	if err != nil {
-		fmt.Printf("Error calling GetContent() on Gemfile for %s/%s: %v\n", owner, name, err)
-		return nil
+		paketo.Others = append(paketo.Others, paketoBuildpackInfo)
+		heroku.Others = append(heroku.Others, herokuBuildpackInfo)
+		return fmt.Errorf("error calling GetContent() on Gemfile for %s/%s: %v", owner, name, err)
 	}
 
 	count := 6
@@ -384,7 +249,7 @@ func (runtime *rubyRuntime) Detect(
 	results := make(chan struct {
 		string
 		bool
-	}, count)
+	})
 
 	fmt.Printf("Starting detection for a Ruby runtime for %s/%s\n", owner, name)
 	runtime.wg.Add(count)
@@ -404,7 +269,7 @@ func (runtime *rubyRuntime) Detect(
 		}
 
 		fmt.Println("Checking for unicorn")
-		runtime.detectUnicorn(gemfileContent, results)
+		go runtime.detectUnicorn(gemfileContent, results)
 	}
 	fmt.Println("Checking for passenger")
 	go runtime.detectPassenger(gemfileContent, results)
@@ -414,60 +279,13 @@ func (runtime *rubyRuntime) Detect(
 	}
 	if rakefileFound {
 		fmt.Println("Checking for rake")
-		runtime.detectRake(gemfileContent, results)
+		go runtime.detectRake(gemfileContent, results)
 	}
 	runtime.wg.Wait()
 	close(results)
 
-	detected := make(map[string]bool)
-	for result := range results {
-		detected[result.string] = result.bool
-	}
-
-	// TODO: how to access config values for Ruby projects
-	if found, ok := detected[puma]; ok && found {
-		fmt.Printf("Ruby puma runtime detected for %s/%s\n", owner, name)
-		return &RuntimeResponse{
-			Name:       "Ruby",
-			Runtime:    puma,
-			Buildpacks: runtime.packs[puma],
-		}
-	} else if found, ok := detected[thin]; ok && found {
-		fmt.Printf("Ruby thin runtime detected for %s/%s\n", owner, name)
-		return &RuntimeResponse{
-			Name:       "Ruby",
-			Runtime:    thin,
-			Buildpacks: runtime.packs[thin],
-		}
-	} else if found, ok := detected[unicorn]; ok && found {
-		fmt.Printf("Ruby unicorn runtime detected for %s/%s\n", owner, name)
-		return &RuntimeResponse{
-			Name:       "Ruby",
-			Runtime:    unicorn,
-			Buildpacks: runtime.packs[unicorn],
-		}
-	} else if found, ok := detected[passenger]; ok && found {
-		fmt.Printf("Ruby passenger runtime detected for %s/%s\n", owner, name)
-		return &RuntimeResponse{
-			Name:       "Ruby",
-			Runtime:    passenger,
-			Buildpacks: runtime.packs[passenger],
-		}
-	} else if found, ok := detected[rackup]; ok && found {
-		fmt.Printf("Ruby rackup runtime detected for %s/%s\n", owner, name)
-		return &RuntimeResponse{
-			Name:       "Ruby",
-			Runtime:    rackup,
-			Buildpacks: runtime.packs[rackup],
-		}
-	} else if found, ok := detected[rake]; ok && found {
-		fmt.Printf("Ruby rake runtime detected for %s/%s\n", owner, name)
-		return &RuntimeResponse{
-			Name:       "Ruby",
-			Runtime:    rake,
-			Buildpacks: runtime.packs[rake],
-		}
-	}
+	paketo.Detected = append(paketo.Detected, paketoBuildpackInfo)
+	heroku.Detected = append(heroku.Detected, herokuBuildpackInfo)
 
-	panic("[api_ruby.go] This should ne never reached")
+	return nil
 }

+ 15 - 30
internal/integrations/buildpacks/shared.go

@@ -28,40 +28,23 @@ const (
 
 	// Common
 	standalone = "standalone"
-)
 
-type buildpackOrderGroupInfo struct {
-	ID       string `json:"id"`
-	Optional bool   `json:"optional"`
-	Version  string `json:"version"`
-}
+	// Builders
+	PaketoBuilder = "paketo"
+	HerokuBuilder = "heroku"
+)
 
 type BuildpackInfo struct {
-	Packs []buildpackOrderGroupInfo `json:"packs"`
-	// FIXME: env vars for https://github.com/paketo-buildpacks/environment-variables
-	//        and for https://github.com/paketo-buildpacks/image-labels
-	EnvVars map[string]string `json:"env_vars"`
-}
-
-func newBuildpackInfo() *BuildpackInfo {
-	return &BuildpackInfo{
-		EnvVars: make(map[string]string),
-	}
-}
-
-func (info *BuildpackInfo) addPack(pack buildpackOrderGroupInfo) {
-	info.Packs = append(info.Packs, pack)
-}
-
-func (info *BuildpackInfo) addEnvVar(id string, val string) {
-	info.EnvVars[id] = val
+	Name      string                 `json:"name"`
+	Buildpack string                 `json:"buildpack"`
+	Config    map[string]interface{} `json:"config"`
 }
 
-type RuntimeResponse struct {
-	Name       string                 `json:"name"`
-	Buildpacks *BuildpackInfo         `json:"buildpacks"`
-	Runtime    string                 `json:"runtime"`
-	Config     map[string]interface{} `json:"config"`
+type BuilderInfo struct {
+	Name     string          `json:"name"`
+	Builders []string        `json:"builders"`
+	Detected []BuildpackInfo `json:"detected"`
+	Others   []BuildpackInfo `json:"others"`
 }
 
 type Runtime interface {
@@ -72,7 +55,9 @@ type Runtime interface {
 		string, // name
 		string, // path
 		github.RepositoryContentGetOptions, // SHA, branch or tag
-	) *RuntimeResponse
+		*BuilderInfo, // paketo
+		*BuilderInfo, // heroku
+	) error
 }
 
 // Runtimes is a list of all API runtimes

+ 65 - 30
internal/integrations/slack/notifier.go

@@ -19,8 +19,9 @@ type Notifier interface {
 type DeploymentStatus string
 
 const (
-	StatusDeployed string = "deployed"
-	StatusFailed   string = "failed"
+	StatusHelmDeployed DeploymentStatus = "helm_deployed"
+	StatusPodCrashed   DeploymentStatus = "pod_crashed"
+	StatusHelmFailed   DeploymentStatus = "helm_failed"
 )
 
 type NotifyOpts struct {
@@ -34,7 +35,7 @@ type NotifyOpts struct {
 	ClusterName string
 
 	// Status is the current status of the deployment.
-	Status string
+	Status DeploymentStatus
 
 	// Info is any additional information about this status, such as an error message if
 	// the deployment failed.
@@ -82,38 +83,29 @@ func (s *SlackNotifier) Notify(opts *NotifyOpts) error {
 		if !s.Config.Enabled {
 			return nil
 		}
-		if opts.Status == StatusDeployed && !s.Config.Success {
+		if opts.Status == StatusHelmDeployed && !s.Config.Success {
 			return nil
 		}
-		if opts.Status == StatusFailed && !s.Config.Failure {
+		if opts.Status == StatusPodCrashed && !s.Config.Failure {
+			return nil
+		}
+		if opts.Status == StatusHelmFailed && !s.Config.Failure {
 			return nil
 		}
-	}
-
-	blocks := []*SlackBlock{
-		getMessageBlock(opts),
-		getDividerBlock(),
-		getMarkdownBlock(fmt.Sprintf("*Name:* %s", "`"+opts.Name+"`")),
-		getMarkdownBlock(fmt.Sprintf("*Namespace:* %s", "`"+opts.Namespace+"`")),
-		getMarkdownBlock(fmt.Sprintf("*Version:* %d", opts.Version)),
 	}
 
 	// we create a basic payload as a fallback if the detailed payload with "info" fails, due to
 	// marshaling errors on the Slack API side.
-	basicSlackPayload := &SlackPayload{
-		Blocks: blocks,
-	}
-
-	infoBlock := getInfoBlock(opts)
-
-	if infoBlock != nil {
-		blocks = append(blocks, infoBlock)
-	}
+	blocks, basicBlocks := getSlackBlocks(opts)
 
 	slackPayload := &SlackPayload{
 		Blocks: blocks,
 	}
 
+	basicSlackPayload := &SlackPayload{
+		Blocks: basicBlocks,
+	}
+
 	basicPayload, err := json.Marshal(basicSlackPayload)
 
 	if err != nil {
@@ -143,6 +135,37 @@ func (s *SlackNotifier) Notify(opts *NotifyOpts) error {
 	return nil
 }
 
+func getSlackBlocks(opts *NotifyOpts) ([]*SlackBlock, []*SlackBlock) {
+	res := []*SlackBlock{}
+
+	if opts.Status == StatusHelmDeployed || opts.Status == StatusHelmFailed {
+		res = append(res, getHelmMessageBlock(opts))
+	} else if opts.Status == StatusPodCrashed {
+		res = append(res, getPodCrashedMessageBlock(opts))
+	}
+
+	res = append(
+		res,
+		getDividerBlock(),
+		getMarkdownBlock(fmt.Sprintf("*Name:* %s", "`"+opts.Name+"`")),
+		getMarkdownBlock(fmt.Sprintf("*Namespace:* %s", "`"+opts.Namespace+"`")),
+	)
+
+	if opts.Status == StatusHelmDeployed || opts.Status == StatusHelmFailed {
+		res = append(res, getMarkdownBlock(fmt.Sprintf("*Version:* %d", opts.Version)))
+	}
+
+	basicRes := res
+
+	infoBlock := getInfoBlock(opts)
+
+	if infoBlock != nil {
+		res = append(res, infoBlock)
+	}
+
+	return res, basicRes
+}
+
 func getDividerBlock() *SlackBlock {
 	return &SlackBlock{
 		Type: "divider",
@@ -159,24 +182,36 @@ func getMarkdownBlock(md string) *SlackBlock {
 	}
 }
 
-func getMessageBlock(opts *NotifyOpts) *SlackBlock {
+func getHelmMessageBlock(opts *NotifyOpts) *SlackBlock {
 	var md string
 
 	switch opts.Status {
-	case StatusDeployed:
-		md = getSuccessMessage(opts)
-	case StatusFailed:
-		md = getFailedMessage(opts)
+	case StatusHelmDeployed:
+		md = getHelmSuccessMessage(opts)
+	case StatusHelmFailed:
+		md = getHelmFailedMessage(opts)
 	}
 
 	return getMarkdownBlock(md)
 }
 
+func getPodCrashedMessageBlock(opts *NotifyOpts) *SlackBlock {
+	md := fmt.Sprintf(
+		":x: Your application %s crashed on Porter. <%s|View the application.>",
+		"`"+opts.Name+"`",
+		opts.URL,
+	)
+
+	return getMarkdownBlock(md)
+}
+
 func getInfoBlock(opts *NotifyOpts) *SlackBlock {
 	var md string
 
 	switch opts.Status {
-	case StatusFailed:
+	case StatusHelmFailed:
+		md = getFailedInfoMessage(opts)
+	case StatusPodCrashed:
 		md = getFailedInfoMessage(opts)
 	default:
 		return nil
@@ -185,7 +220,7 @@ func getInfoBlock(opts *NotifyOpts) *SlackBlock {
 	return getMarkdownBlock(md)
 }
 
-func getSuccessMessage(opts *NotifyOpts) string {
+func getHelmSuccessMessage(opts *NotifyOpts) string {
 	return fmt.Sprintf(
 		":rocket: Your application %s was successfully updated on Porter! <%s|View the new release.>",
 		"`"+opts.Name+"`",
@@ -193,7 +228,7 @@ func getSuccessMessage(opts *NotifyOpts) string {
 	)
 }
 
-func getFailedMessage(opts *NotifyOpts) string {
+func getHelmFailedMessage(opts *NotifyOpts) string {
 	return fmt.Sprintf(
 		":x: Your application %s failed to deploy on Porter. <%s|View the status here.>",
 		"`"+opts.Name+"`",

+ 25 - 0
internal/kubernetes/agent.go

@@ -267,6 +267,17 @@ func (a *Agent) ListNamespaces() (*v1.NamespaceList, error) {
 
 // CreateNamespace creates a namespace with the given name.
 func (a *Agent) CreateNamespace(name string) (*v1.Namespace, error) {
+	// check if namespace exists
+	checkNS, err := a.Clientset.CoreV1().Namespaces().Get(
+		context.TODO(),
+		name,
+		metav1.GetOptions{},
+	)
+
+	if err == nil && checkNS != nil {
+		return checkNS, nil
+	}
+
 	namespace := v1.Namespace{
 		ObjectMeta: metav1.ObjectMeta{
 			Name: name,
@@ -289,6 +300,20 @@ func (a *Agent) DeleteNamespace(name string) error {
 	)
 }
 
+func (a *Agent) GetPorterAgent() (*appsv1.Deployment, error) {
+	depl, err := a.Clientset.AppsV1().Deployments("porter-agent-system").Get(
+		context.TODO(),
+		"porter-agent-controller-manager",
+		metav1.GetOptions{},
+	)
+
+	if err != nil && errors.IsNotFound(err) {
+		return nil, IsNotFoundError
+	}
+
+	return depl, err
+}
+
 // ListJobsByLabel lists jobs in a namespace matching a label
 type Label struct {
 	Key string

+ 124 - 0
internal/kubernetes/porter_agent/logs.go

@@ -0,0 +1,124 @@
+package porter_agent
+
+import (
+	"context"
+	"encoding/json"
+	"fmt"
+
+	v1 "k8s.io/api/core/v1"
+	"k8s.io/client-go/kubernetes"
+
+	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+)
+
+// returns the agent service
+func GetAgentService(clientset kubernetes.Interface) (*v1.Service, error) {
+	return clientset.CoreV1().Services("porter-agent-system").Get(
+		context.TODO(),
+		"porter-agent-controller-manager",
+		metav1.GetOptions{},
+	)
+}
+
+type SimpleIngress struct {
+	Name      string `json:"name"`
+	Namespace string `json:"namespace"`
+}
+
+type LogPathOpts struct {
+	Timestamp int
+	Pod       string
+	Namespace string
+}
+
+func GetLogsFromPorterAgent(
+	clientset kubernetes.Interface,
+	service *v1.Service,
+	opts *LogPathOpts,
+) (*AgentLogsResp, error) {
+	if len(service.Spec.Ports) == 0 {
+		return nil, fmt.Errorf("agent service has no exposed ports to query")
+	}
+
+	resp := clientset.CoreV1().Services(service.Namespace).ProxyGet(
+		"http",
+		service.Name,
+		fmt.Sprintf("%d", service.Spec.Ports[0].Port),
+		fmt.Sprintf("/pod/%s/ns/%s/logbucket/%d", opts.Pod, opts.Namespace, opts.Timestamp),
+		nil,
+	)
+
+	rawQuery, err := resp.DoRaw(context.TODO())
+
+	if err != nil {
+		return nil, err
+	}
+
+	return parseLogQuery(rawQuery)
+}
+
+type LogBucketPathOpts struct {
+	Pod       string
+	Namespace string
+}
+
+func GetLogBucketsFromPorterAgent(
+	clientset kubernetes.Interface,
+	service *v1.Service,
+	opts *LogBucketPathOpts,
+) (*AgentLogBucketsResp, error) {
+	if len(service.Spec.Ports) == 0 {
+		return nil, fmt.Errorf("agent service has no exposed ports to query")
+	}
+
+	resp := clientset.CoreV1().Services(service.Namespace).ProxyGet(
+		"http",
+		service.Name,
+		fmt.Sprintf("%d", service.Spec.Ports[0].Port),
+		fmt.Sprintf("/pod/%s/ns/%s/logbucket", opts.Pod, opts.Namespace),
+		nil,
+	)
+
+	rawQuery, err := resp.DoRaw(context.TODO())
+
+	if err != nil {
+		return nil, err
+	}
+
+	return parseLogBucketsQuery(rawQuery)
+}
+
+type AgentLogsResp struct {
+	Logs          []string `json:"logs"`
+	MatchedBucket string   `json:"matchedBucket"`
+	Error         string   `json:"error"`
+}
+
+func parseLogQuery(rawQuery []byte) (*AgentLogsResp, error) {
+	resp := &AgentLogsResp{}
+
+	err := json.Unmarshal(rawQuery, resp)
+
+	if err != nil {
+		return nil, err
+	}
+
+	return resp, nil
+}
+
+type AgentLogBucketsResp struct {
+	AvailableBuckets []string `json:"availableLogBuckets"`
+	Error            string   `json:"error"`
+}
+
+func parseLogBucketsQuery(rawQuery []byte) (*AgentLogBucketsResp, error) {
+	resp := &AgentLogBucketsResp{}
+
+	err := json.Unmarshal(rawQuery, resp)
+
+	if err != nil {
+		return nil, err
+	}
+
+	return resp, nil
+}

+ 6 - 5
internal/models/build_config.go

@@ -1,6 +1,8 @@
 package models
 
 import (
+	"strings"
+
 	"github.com/porter-dev/porter/api/types"
 	"gorm.io/gorm"
 )
@@ -9,16 +11,15 @@ type BuildConfig struct {
 	gorm.Model
 
 	Name       string `json:"name"`
-	Runtime    string `json:"runtime"`
-	Buildpacks []byte `json:"buildpacks"` // FIXME: should be a []string
+	Builder    string `json:"runtime"`
+	Buildpacks string `json:"buildpacks"`
 	Config     []byte `json:"config"`
 }
 
 func (conf *BuildConfig) ToBuildConfigType() *types.BuildConfig {
 	return &types.BuildConfig{
-		Name:       conf.Name,
-		Runtime:    conf.Runtime,
-		Buildpacks: conf.Buildpacks,
+		Builder:    conf.Builder,
+		Buildpacks: strings.Split(conf.Buildpacks, ","),
 		Config:     conf.Config,
 	}
 }

+ 77 - 0
internal/models/kube_events.go

@@ -0,0 +1,77 @@
+package models
+
+import (
+	"time"
+
+	"github.com/porter-dev/porter/api/types"
+	"gorm.io/gorm"
+)
+
+// KubeEvent model refers to a type of event from a Kubernetes cluster
+type KubeEvent struct {
+	gorm.Model
+
+	ProjectID uint
+	ClusterID uint
+
+	// The name of the referenced kube object
+	Name string
+
+	// The kube resource type, such as "pod", "hpa", or "node"
+	ResourceType string
+
+	// (optional) The owner reference type and name, which can be used to filter events by
+	// controller
+	OwnerType string
+	OwnerName string
+
+	// (optional) the namespace of the event, if namespaceable
+	Namespace string
+
+	// The "subevents" attached to the event. These are a grouped collection of events that belong
+	// to the same object.
+	SubEvents []KubeSubEvent
+}
+
+type KubeSubEvent struct {
+	gorm.Model
+
+	KubeEventID uint
+	Message     string
+	Reason      string
+	Timestamp   time.Time
+
+	// The event type, such as "critical" or "normal"
+	EventType types.KubeEventType
+}
+
+func (k *KubeSubEvent) ToKubeSubEventType() *types.KubeSubEvent {
+	return &types.KubeSubEvent{
+		Message:   k.Message,
+		Reason:    k.Reason,
+		Timestamp: k.Timestamp,
+		EventType: k.EventType,
+	}
+}
+
+func (k *KubeEvent) ToKubeEventType() *types.KubeEvent {
+	subEvents := make([]*types.KubeSubEvent, 0)
+
+	for _, subEvent := range k.SubEvents {
+		subEvents = append(subEvents, subEvent.ToKubeSubEventType())
+	}
+
+	return &types.KubeEvent{
+		CreatedAt:    k.CreatedAt,
+		UpdatedAt:    k.UpdatedAt,
+		ID:           k.ID,
+		ProjectID:    k.ProjectID,
+		ClusterID:    k.ClusterID,
+		ResourceType: k.ResourceType,
+		Name:         k.Name,
+		Namespace:    k.Namespace,
+		OwnerType:    k.OwnerType,
+		OwnerName:    k.OwnerName,
+		SubEvents:    subEvents,
+	}
+}

この差分においてかなりの量のファイルが変更されているため、一部のファイルを表示していません