Browse Source

Merge branch 'belanger/por-160-pod-events-backend' of https://github.com/porter-dev/porter into belanger/por-160-pod-events-backend

jusrhee 4 years ago
parent
commit
70f7733f79
46 changed files with 3280 additions and 405 deletions
  1. 3 0
      .gitignore
  2. 51 0
      api/server/handlers/cluster/detect_agent_installed.go
  3. 111 0
      api/server/handlers/cluster/install_agent.go
  4. 213 0
      api/server/handlers/kube_events/create.go
  5. 46 0
      api/server/handlers/kube_events/get.go
  6. 96 0
      api/server/handlers/kube_events/get_log_buckets.go
  7. 97 0
      api/server/handlers/kube_events/get_logs.go
  8. 60 0
      api/server/handlers/kube_events/list.go
  9. 1 1
      api/server/handlers/release/get_steps.go
  10. 6 5
      api/server/handlers/release/ugprade.go
  11. 3 3
      api/server/handlers/release/update_steps.go
  12. 5 4
      api/server/handlers/release/upgrade_webhook.go
  13. 200 0
      api/server/router/cluster.go
  14. 91 0
      api/types/kube_events.go
  15. 2 0
      api/types/release.go
  16. 13 0
      dashboard/package-lock.json
  17. 1 0
      dashboard/package.json
  18. 157 0
      dashboard/src/components/Dropdown.tsx
  19. 238 0
      dashboard/src/components/events/EventCard.tsx
  20. 161 0
      dashboard/src/components/events/SubEventsList.tsx
  21. 149 0
      dashboard/src/components/events/sub-events/LogBucketCard.tsx
  22. 36 40
      dashboard/src/components/events/sub-events/SubEventCard.tsx
  23. 190 0
      dashboard/src/components/events/useEvents.ts
  24. 1 1
      dashboard/src/main/home/Home.tsx
  25. 5 1
      dashboard/src/main/home/cluster-dashboard/dashboard/Dashboard.tsx
  26. 190 0
      dashboard/src/main/home/cluster-dashboard/dashboard/events/EventsTab.tsx
  27. 1 1
      dashboard/src/main/home/cluster-dashboard/expanded-chart/ExpandedChart.tsx
  28. 0 94
      dashboard/src/main/home/cluster-dashboard/expanded-chart/events/EventDetail.tsx
  29. 138 172
      dashboard/src/main/home/cluster-dashboard/expanded-chart/events/EventsTab.tsx
  30. 63 0
      dashboard/src/shared/api.tsx
  31. 15 0
      dashboard/src/shared/types.tsx
  32. 3 0
      ee/docker/ee.Dockerfile
  33. 65 30
      internal/integrations/slack/notifier.go
  34. 25 0
      internal/kubernetes/agent.go
  35. 124 0
      internal/kubernetes/porter_agent/logs.go
  36. 77 0
      internal/models/kube_events.go
  37. 19 3
      internal/models/notification.go
  38. 18 2
      internal/repository/event.go
  39. 246 11
      internal/repository/gorm/event.go
  40. 201 0
      internal/repository/gorm/event_test.go
  41. 87 19
      internal/repository/gorm/helpers_test.go
  42. 2 0
      internal/repository/gorm/migrate.go
  43. 10 4
      internal/repository/gorm/repository.go
  44. 2 1
      internal/repository/repository.go
  45. 48 9
      internal/repository/test/event.go
  46. 10 4
      internal/repository/test/repository.go

+ 3 - 0
.gitignore

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

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

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

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

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

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

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

+ 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

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

+ 13 - 0
dashboard/package-lock.json

@@ -9444,6 +9444,14 @@
         "@babel/runtime": "^7.12.5"
       }
     },
+    "react-infinite-scroll-component": {
+      "version": "6.1.0",
+      "resolved": "https://registry.npmjs.org/react-infinite-scroll-component/-/react-infinite-scroll-component-6.1.0.tgz",
+      "integrity": "sha512-SQu5nCqy8DxQWpnUVLx7V7b7LcA37aM7tvoWjTLZp1dk6EJibM5/4EJKzOnl07/BsM1Y40sKLuqjCwwH/xV0TQ==",
+      "requires": {
+        "throttle-debounce": "^2.1.0"
+      }
+    },
     "react-is": {
       "version": "16.13.1",
       "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
@@ -11094,6 +11102,11 @@
         }
       }
     },
+    "throttle-debounce": {
+      "version": "2.3.0",
+      "resolved": "https://registry.npmjs.org/throttle-debounce/-/throttle-debounce-2.3.0.tgz",
+      "integrity": "sha512-H7oLPV0P7+jgvrk+6mwwwBDmxTaxnu9HMXmloNLXwnNO0ZxZ31Orah2n8lU1eMPvsaowP2CX+USCgyovXfdOFQ=="
+    },
     "through2": {
       "version": "2.0.5",
       "resolved": "https://registry.npmjs.org/through2/-/through2-2.0.5.tgz",

+ 1 - 0
dashboard/package.json

@@ -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;
+  }
+`;

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

@@ -0,0 +1,238 @@
+import React, { useState } from "react";
+import styled from "styled-components";
+
+import Loading from "components/Loading";
+import { KubeEvent } from "shared/types";
+
+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>
+        <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>
+              <Helper>Last message seen:</Helper>
+              {event.last_message}
+            </EventReason>
+          </EventInformation>
+        </ContentContainer>
+        <ActionContainer hasOneChild={event.event_type === "normal"}>
+          {event.sub_events?.length && (
+            <HistoryButton
+              onClick={() => selectEvent(event)}
+              onMouseEnter={() => setShowTooltip(true)}
+              onMouseLeave={() => setShowTooltip(false)}
+            >
+              <span className="material-icons-outlined">manage_search</span>
+              {showTooltip && <Tooltip>Open logs</Tooltip>}
+            </HistoryButton>
+          )}
+          <TimestampContainer>
+            <TimestampIcon className="material-icons-outlined">
+              access_time
+            </TimestampIcon>
+            <span>{getReadableDate(event.timestamp)}</span>
+          </TimestampContainer>
+        </ActionContainer>
+      </StyledCard>
+    </>
+  );
+};
+
+export default EventCard;
+
+// const StyledCard = styled.div`
+//   background: #26282f;
+//   min-height: 100px;
+//   width: 100%;
+//   display: flex;
+
+//   align-items: center;
+//   border: 1px solid #26282f;
+//   box-shadow: 0 4px 15px 0px #00000055;
+//   border-radius: 8px;
+//   padding: 14px;
+//   animation: fadeIn 0.5s;
+//   @keyframes fadeIn {
+//     from {
+//       opacity: 0;
+//     }
+//     to {
+//       opacity: 1;
+//     }
+//   }
+// `;
+
+const StyledCard = styled.div`
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  border: 1px solid #ffffff44;
+  background: #ffffff08;
+  margin-bottom: 10px;
+  border-radius: 10px;
+  padding: 20px 14px 14px 14px;
+  overflow: hidden;
+  height: 95px;
+  cursor: pointer;
+  :hover {
+    background: #ffffff11;
+    border: 1px solid #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`
+  font-size: 35px;
+  margin-right: 14px;
+  color: ${({ status }: { status: "critical" | "normal" }) =>
+    status === "critical" ? "red" : "green"};
+`;
+
+const EventInformation = styled.div`
+  display: flex;
+  flex-direction: column;
+  justify-content: space-around;
+  height: 100%;
+`;
+
+const EventName = styled.div`
+  font-size: 14px;
+  font-family: "Work Sans", sans-serif;
+  font-weight: 500;
+  color: #ffffff;
+`;
+
+const Helper = styled.span`
+  font-size: 14px;
+  text-transform: capitalize;
+  color: #ffffff44;
+  margin-right: 5px;
+`;
+
+const EventReason = styled.div`
+  font-size: 16px;
+  font-family: "Work Sans", sans-serif;
+  color: #ffffff;
+  margin-top: 8px;
+`;
+
+const ActionContainer = styled.div`
+  width: max-content;
+  display: flex;
+  align-items: center;
+  white-space: nowrap;
+  height: 100%;
+  flex-direction: column;
+  justify-content: ${(props: { hasOneChild: boolean }) => {
+    return props.hasOneChild ? "flex-end" : "space-between";
+  }};
+`;
+
+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;
+  min-width: 130px;
+  justify-content: space-between;
+`;
+
+const TimestampIcon = styled.span`
+  margin-right: 5px;
+`;

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

@@ -0,0 +1,161 @@
+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 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 (
+    <>
+      <ControlRow>
+        <div>
+          <BackButton onClick={clearSelectedEvent}>
+            <BackButtonImg src={backArrow} />
+          </BackButton>
+        </div>
+      </ControlRow>
+      {isLoading ? (
+        <Loading />
+      ) : sortedSubEvents?.length ? (
+        <EventsGrid>
+          {sortedSubEvents.map((subEvent: any) => {
+            if (subEvent?.event_type === "log_bucket") {
+              return <LogBucketCard logEvent={subEvent} />;
+            }
+            return <SubEventCard subEvent={subEvent} />;
+          })}
+        </EventsGrid>
+      ) : (
+        "No sub events found for this resource "
+      )}
+    </>
+  );
+};
+
+export default SubEventsList;
+
+const ControlRow = styled.div`
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  margin-top: 35px;
+  margin-bottom: 35px;
+  padding-left: 0px;
+`;
+
+const BackButton = styled.div`
+  display: flex;
+  width: 36px;
+  cursor: pointer;
+  height: 36px;
+  align-items: center;
+  justify-content: center;
+  border: 1px solid #ffffff55;
+  border-radius: 100px;
+  background: #ffffff11;
+  :hover {
+    background: #ffffff22;
+    > img {
+      opacity: 1;
+    }
+  }
+`;
+
+const BackButtonImg = styled.img`
+  width: 16px;
+  opacity: 0.75;
+`;
+
+const EventsGrid = styled.div`
+  display: grid;
+  grid-row-gap: 15px;
+  grid-template-columns: 1;
+`;

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

@@ -0,0 +1,149 @@
+import React, { useContext, useEffect, useState } from "react";
+import api from "shared/api";
+import { Context } from "shared/Context";
+import styled from "styled-components";
+
+type LogBucketCardProps = {
+  logEvent: any;
+};
+
+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 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 && (!Array.isArray(logs) || !logs.length)) {
+      getLogsForBucket();
+    }
+  }, [currentProject, currentCluster, logEvent, isExpanded]);
+
+  return (
+    <StyledCard>
+      <div style={{ display: "flex", alignItems: "center" }}>
+        <Icon className="material-icons-outlined">info</Icon>
+        <InfoWrapper>
+          <div>
+            <EventName>Logs for: {logEvent.resource_name}</EventName>
+          </div>
+        </InfoWrapper>
+      </div>
+      <InfoWrapper>
+        <TimestampContainer>
+          <i className="material-icons-outlined">access_time</i>
+          {getReadableDate(logEvent.timestamp)}
+        </TimestampContainer>
+        <button onClick={() => setIsExpanded((expanded) => !expanded)}>
+          Show more
+        </button>
+      </InfoWrapper>
+
+      {/* Case: Is still getting logs and user triggered expanded */}
+      {isExpanded && isLoading && "Loading"}
+      {/* Case: No logs found after the api call */}
+      {isExpanded && !isLoading && !logs?.length && "No logs found"}
+      {/* Case: Logs were found successfully  */}
+      {isExpanded &&
+        !isLoading &&
+        logs?.length &&
+        logs?.map((l) => <span>{l}</span>)}
+    </StyledCard>
+  );
+};
+
+export default LogBucketCard;
+
+const StyledCard = styled.div`
+  display: flex;
+  flex-direction: row;
+  align-items: center;
+  border: 1px solid #ffffff44;
+  background: #ffffff08;
+  margin-bottom: 10px;
+  border-radius: 10px;
+  padding-left: 20px;
+  padding-right: 20px;
+  overflow: hidden;
+  height: 80px;
+  cursor: pointer;
+  justify-content: space-between;
+
+  :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"};
+`;
+
+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;
+  justify-self: flex-end;
+
+  > i {
+    margin-right: 5px;
+    font-size: 18px;
+    margin-left: -1px;
+  }
+`;
+
+const EventReason = styled.div`
+  font-size: 16px;
+  font-family: "Work Sans", sans-serif;
+  color: #ffffff;
+  margin-top: 8px;
+`;

+ 36 - 40
dashboard/src/main/home/cluster-dashboard/expanded-chart/events/EventCard.tsx → dashboard/src/components/events/sub-events/SubEventCard.tsx

@@ -1,16 +1,12 @@
 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;
+  subEvent: any;
 };
 
-export const getReadableDate = (s: number) => {
-  let ts = new Date(s * 1000);
+const getReadableDate = (s: number) => {
+  let ts = new Date(s);
   let date = ts.toLocaleDateString();
   let time = ts.toLocaleTimeString([], {
     hour: "numeric",
@@ -19,57 +15,49 @@ export const getReadableDate = (s: number) => {
   return `${time} ${date}`;
 };
 
-// Rename to Event Card
-const EventCard: React.FunctionComponent<CardProps> = ({
-  event,
-  selectEvent,
-  overrideName,
-}) => {
+const SubEventCard: React.FunctionComponent<CardProps> = ({ subEvent }) => {
   return (
-    <StyledCard onClick={() => selectEvent && selectEvent()}>
-      {event.status == 1 && (
-        <Icon status="normal" className="material-icons-outlined">
-          check
+    <StyledCard>
+      <div style={{ display: "flex", alignItems: "center" }}>
+        <Icon
+          status={subEvent.event_type.toLowerCase() as any}
+          className="material-icons-outlined"
+        >
+          {subEvent.event_type.toLowerCase() === "critical"
+            ? "report_problem"
+            : "info"}
         </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>
+        <InfoWrapper>
+          <div>
+            <EventName>Event type: {subEvent.event_type}</EventName>
+            <EventReason>Detail: {subEvent.message}</EventReason>
+          </div>
+        </InfoWrapper>
+      </div>
+      <TimestampContainer>
+        <i className="material-icons-outlined">access_time</i>
+        {getReadableDate(subEvent.timestamp)}
+      </TimestampContainer>
     </StyledCard>
   );
 };
 
-export default EventCard;
+export default SubEventCard;
 
 const StyledCard = styled.div`
   display: flex;
+  flex-direction: row;
   align-items: center;
   border: 1px solid #ffffff44;
   background: #ffffff08;
   margin-bottom: 10px;
   border-radius: 10px;
   padding-left: 20px;
+  padding-right: 20px;
   overflow: hidden;
   height: 80px;
   cursor: pointer;
+  justify-content: space-between;
 
   :hover {
     background: #ffffff11;
@@ -111,6 +99,7 @@ const TimestampContainer = styled.div`
   color: #ffffff55;
   font-size: 13px;
   margin-top: 8px;
+  justify-self: flex-end;
 
   > i {
     margin-right: 5px;
@@ -118,3 +107,10 @@ const TimestampContainer = styled.div`
     margin-left: -1px;
   }
 `;
+
+const EventReason = styled.div`
+  font-size: 16px;
+  font-family: "Work Sans", sans-serif;
+  color: #ffffff;
+  margin-top: 8px;
+`;

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

@@ -0,0 +1,190 @@
+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";
+
+export const useKubeEvents = (
+  resourceType: "NODE" | "POD" | "HPA",
+  ownerName?: string,
+  ownerType?: string
+) => {
+  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 (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
+      .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,
+  };
+};

+ 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

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

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

@@ -0,0 +1,190 @@
+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.value as any);
+
+  if (isLoading) {
+    return (
+      <Placeholder>
+        <Loading />
+      </Placeholder>
+    );
+  }
+
+  if (!hasPorterAgent) {
+    return (
+      <Placeholder>
+        <div>
+          <Header>We coulnd't detect porter agent :(</Header>
+          In order to use the events tab you should install the porter agent!
+          <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>
+      <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"
+        endMessage={
+          <h4>No events were found for the resource type you specified</h4>
+        }
+      >
+        <EventsGrid>
+          {kubeEvents.map((event, i) => {
+            return (
+              <React.Fragment key={i}>
+                <EventCard
+                  event={event}
+                  selectEvent={() => {
+                    setCurrentEvent(event);
+                  }}
+                />
+              </React.Fragment>
+            );
+          })}
+        </EventsGrid>
+      </InfiniteScroll>
+    </EventsPageWrapper>
+  );
+};
+
+export default EventsTab;
+
+const RightFilters = styled.div`
+  display: flex;
+  > div {
+    :not(:last-child) {
+      margin-right: 15px;
+    }
+  }
+`;
+
+const ControlRow = styled.div`
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  margin-bottom: 35px;
+  padding-left: 0px;
+`;
+
+const EventsPageWrapper = styled.div`
+  margin-top: 35px;
+  padding-bottom: 80px;
+`;
+
+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: 20px;
+  color: white;
+  height: 35px;
+  padding: 0px 8px;
+  padding-bottom: 1px;
+  margin-top: 10px;
+  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" : "#616FEEcc"};
+  :hover {
+    background: ${(props: { disabled?: boolean }) =>
+      props.disabled ? "" : "#505edddd"};
+  }
+  > i {
+    color: white;
+    width: 18px;
+    height: 18px;
+    font-weight: 600;
+    font-size: 12px;
+    border-radius: 20px;
+    display: flex;
+    align-items: center;
+    margin-right: 5px;
+    justify-content: center;
+  }
+`;
+
+const Placeholder = styled.div`
+  min-height: 200px;
+  height: 20vh;
+  padding: 30px;
+  padding-bottom: 90px;
+  font-size: 13px;
+  color: #ffffff44;
+  width: 100%;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+`;
+
+const Header = styled.div`
+  font-weight: 500;
+  color: #aaaabb;
+  font-size: 16px;
+  margin-bottom: 15px;
+`;

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

@@ -352,7 +352,7 @@ const ExpandedChart: React.FC<Props> = (props) => {
       case "metrics":
         return <MetricsSection currentChart={chart} />;
       case "events":
-        return <EventsTab currentChart={chart} />;
+        return <EventsTab controllers={controllers} />;
       case "status":
         if (isLoadingChartData) {
           return (

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

+ 138 - 172
dashboard/src/main/home/cluster-dashboard/expanded-chart/events/EventsTab.tsx

@@ -1,129 +1,65 @@
-import React, { useContext, useEffect, useState } from "react";
+import React, { 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";
-
-export type Event = {
-  event_id: string;
-  index: number;
-  info: string;
-  name: string;
-  status: number;
-  time: number;
-};
-
-export type EventContainer = {
-  events: Event[];
-  name: string;
-  started_at: number;
-};
-
-type Props = {
-  currentChart: ChartType;
-};
-
-const REFRESH_TIME = 15000;
-
-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);
-
-  // 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);
-
-    if (data.length == 0) return;
-
-    let seq: Event[][] = [];
-    let cur: Event[] = [data[0]];
-
-    for (let i = 1; i < data.length; ++i) {
-      if (data[i].index < data[i - 1].index) {
-        seq.push(cur);
-        cur = [];
-      }
-      cur.push(data[i]);
+import InfiniteScroll from "react-infinite-scroll-component";
+import Dropdown from "components/Dropdown";
+import { useKubeEvents } from "components/events/useEvents";
+import { ChartType } from "shared/types";
+import _, { isObject } from "lodash";
+import SubEventsList from "components/events/SubEventsList";
+
+const availableResourceTypes = [
+  { label: "Pods", value: "pod" },
+  { label: "HPA", value: "hpa" },
+];
+
+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);
+
+  const [selectedControllerKey, setSelectedControllerKey] = useState(null);
+
+  const controllerOptions = useMemo(() => {
+    if (typeof controllers !== "object") {
+      return [];
     }
-    if (cur) seq.push(cur);
-
-    let ret: EventContainer[] = [];
-    seq.forEach((j) => {
-      j.push({
-        event_id: "",
-        index: 0,
-        info: "",
-        name: "",
-        status: 0,
-        time: 0,
-      });
 
-      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]);
-        }
-      }
-      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);
-        });
-    };
-
-    getData();
-    const id = window.setInterval(getData, REFRESH_TIME);
+    return Object.entries(controllers).map(([key, value]) => ({
+      label: value?.metadata?.name,
+      value: key,
+    }));
+  }, [controllers]);
 
-    return () => {
-      setIsLoading(true);
-      window.clearInterval(id);
-    };
-  }, [currentProject, currentCluster, props.currentChart]);
+  const currentControllerOption = useMemo(() => {
+    return (
+      controllerOptions?.find((c) => c.value === selectedControllerKey) ||
+      controllerOptions[0]
+    );
+  }, [selectedControllerKey, controllerOptions]);
+
+  const selectedController = controllers[currentControllerOption?.value];
+
+  console.log(controllers, currentControllerOption);
+  const {
+    isLoading,
+    hasPorterAgent,
+    triggerInstall,
+    kubeEvents,
+    loadMoreEvents,
+    hasMore,
+  } = useKubeEvents(
+    resourceType.value as any,
+    selectedController?.metadata?.name,
+    selectedController?.kind
+  );
 
-  if (isError) {
-    return <Placeholder>Error loading events.</Placeholder>;
-  }
+  const hasControllers = controllers && Object.keys(controllers)?.length;
 
-  if (isLoading) {
+  if (isLoading || !hasControllers) {
     return (
       <Placeholder>
         <Loading />
@@ -131,57 +67,105 @@ const EventsTab: React.FunctionComponent<Props> = (props) => {
     );
   }
 
-  if (eventData.length === 0) {
+  if (!hasPorterAgent) {
     return (
       <Placeholder>
-        <i className="material-icons">category</i>
-        No application events found.
+        <div>
+          <Header>We coulnd't detect porter agent :(</Header>
+          In order to use the events tab you should install the porter agent!
+          <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"}
-              />
-            </React.Fragment>
-          );
-        })}
-    </EventsGrid>
+    <EventsPageWrapper>
+      <ControlRow>
+        <Dropdown
+          selectedOption={resourceType}
+          options={availableResourceTypes}
+          onSelect={(o) => setResourceType({ ...o, value: o.value as string })}
+        />
+        <RightFilters>
+          <Dropdown
+            selectedOption={currentControllerOption}
+            options={controllerOptions}
+            onSelect={(o) => setSelectedControllerKey(o?.value)}
+          />
+        </RightFilters>
+      </ControlRow>
+
+      <InfiniteScroll
+        dataLength={kubeEvents.length}
+        next={loadMoreEvents}
+        hasMore={hasMore}
+        loader={<h4>Loading...</h4>}
+        scrollableTarget="HomeViewWrapper"
+        endMessage={
+          <h4>No events were found for the resource type you specified</h4>
+        }
+      >
+        <EventsGrid>
+          {kubeEvents.map((event, i) => {
+            return (
+              <React.Fragment key={i}>
+                <EventCard
+                  event={event as any}
+                  selectEvent={() => {
+                    setCurrentEvent(event);
+                  }}
+                />
+              </React.Fragment>
+            );
+          })}
+        </EventsGrid>
+      </InfiniteScroll>
+    </EventsPageWrapper>
   );
 };
 
 export default EventsTab;
 
+const RightFilters = styled.div`
+  display: flex;
+  > div {
+    :not(:last-child) {
+      margin-right: 15px;
+    }
+  }
+`;
+
+const ControlRow = styled.div`
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  margin-bottom: 35px;
+  padding-left: 0px;
+`;
+
 const EventsPageWrapper = styled.div`
   margin-top: 35px;
   padding-bottom: 80px;
 `;
 
+const EventsGrid = styled.div`
+  display: grid;
+  grid-row-gap: 15px;
+  grid-template-columns: 1;
+`;
+
 const InstallPorterAgentButton = styled.button`
   display: flex;
   flex-direction: row;
@@ -205,14 +189,12 @@ 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"};
   :hover {
     background: ${(props: { disabled?: boolean }) =>
       props.disabled ? "" : "#505edddd"};
   }
-
   > i {
     color: white;
     width: 18px;
@@ -228,19 +210,16 @@ const InstallPorterAgentButton = styled.button`
 `;
 
 const Placeholder = styled.div`
+  min-height: 200px;
+  height: 20vh;
+  padding: 30px;
+  padding-bottom: 90px;
+  font-size: 13px;
+  color: #ffffff44;
   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;
-  }
 `;
 
 const Header = styled.div`
@@ -249,16 +228,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;
-`;

+ 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,
 };

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

@@ -331,3 +331,18 @@ 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;
+};

+ 3 - 0
ee/docker/ee.Dockerfile

@@ -47,6 +47,9 @@ RUN npm i
 
 ENV NODE_ENV=production
 
+# TODO: remove this, but gets around https://github.com/webpack/webpack/issues/14532 for now
+ENV NODE_OPTIONS=--openssl-legacy-provider
+
 RUN npm run build
 
 # Deployment environment

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

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

+ 19 - 3
internal/models/notification.go

@@ -1,6 +1,8 @@
 package models
 
 import (
+	"time"
+
 	"github.com/porter-dev/porter/api/types"
 	"gorm.io/gorm"
 )
@@ -12,12 +14,26 @@ type NotificationConfig struct {
 
 	Success bool
 	Failure bool
+
+	LastNotifiedTime time.Time
+	NotifLimit       string
 }
 
 func (conf *NotificationConfig) ToNotificationConfigType() *types.NotificationConfig {
 	return &types.NotificationConfig{
-		Enabled: conf.Enabled,
-		Success: conf.Success,
-		Failure: conf.Failure,
+		Enabled:    conf.Enabled,
+		Success:    conf.Success,
+		Failure:    conf.Failure,
+		NotifLimit: conf.NotifLimit,
 	}
 }
+
+func (conf *NotificationConfig) ShouldNotify() bool {
+	// check the last notified time against the notification limit
+	return conf.LastNotifiedTime.Before(notifLimitToTime(conf.NotifLimit))
+}
+
+func notifLimitToTime(notifTime string) time.Time {
+	// TODO: compute a time that's not just 5 min
+	return time.Now().Add(-10 * time.Minute)
+}

+ 18 - 2
internal/repository/event.go

@@ -1,8 +1,11 @@
 package repository
 
-import "github.com/porter-dev/porter/internal/models"
+import (
+	"github.com/porter-dev/porter/api/types"
+	"github.com/porter-dev/porter/internal/models"
+)
 
-type EventRepository interface {
+type BuildEventRepository interface {
 	CreateEventContainer(am *models.EventContainer) (*models.EventContainer, error)
 	CreateSubEvent(am *models.SubEvent) (*models.SubEvent, error)
 	ReadEventsByContainerID(id uint) ([]*models.SubEvent, error)
@@ -10,3 +13,16 @@ type EventRepository interface {
 	ReadSubEvent(id uint) (*models.SubEvent, error)
 	AppendEvent(container *models.EventContainer, event *models.SubEvent) error
 }
+
+type KubeEventRepository interface {
+	CreateEvent(event *models.KubeEvent) (*models.KubeEvent, error)
+	AppendSubEvent(event *models.KubeEvent, subEvent *models.KubeSubEvent) error
+	ReadEvent(id uint, projID uint, clusterID uint) (*models.KubeEvent, error)
+	ReadEventByGroup(projID uint, clusterID uint, opts *types.GroupOptions) (*models.KubeEvent, error)
+	ListEventsByProjectID(
+		projectID uint,
+		clusterID uint,
+		opts *types.ListKubeEventRequest,
+	) ([]*models.KubeEvent, int64, error)
+	DeleteEvent(id uint) error
+}

+ 246 - 11
internal/repository/gorm/event.go

@@ -1,37 +1,42 @@
 package gorm
 
 import (
+	"fmt"
+	"strings"
+	"time"
+
+	"github.com/porter-dev/porter/api/types"
 	"github.com/porter-dev/porter/internal/models"
 	"github.com/porter-dev/porter/internal/repository"
 	"gorm.io/gorm"
 )
 
-// EventRepository holds both EventContainer and SubEvent models
-type EventRepository struct {
+// BuildEventRepository holds both EventContainer and SubEvent models
+type BuildEventRepository struct {
 	db *gorm.DB
 }
 
-// NewEventRepository returns a EventRepository which uses
+// NewBuildEventRepository returns a BuildEventRepository which uses
 // gorm.DB for querying the database
-func NewEventRepository(db *gorm.DB) repository.EventRepository {
-	return &EventRepository{db}
+func NewBuildEventRepository(db *gorm.DB) repository.BuildEventRepository {
+	return &BuildEventRepository{db}
 }
 
-func (repo EventRepository) CreateEventContainer(am *models.EventContainer) (*models.EventContainer, error) {
+func (repo BuildEventRepository) CreateEventContainer(am *models.EventContainer) (*models.EventContainer, error) {
 	if err := repo.db.Create(am).Error; err != nil {
 		return nil, err
 	}
 	return am, nil
 }
 
-func (repo EventRepository) CreateSubEvent(am *models.SubEvent) (*models.SubEvent, error) {
+func (repo BuildEventRepository) CreateSubEvent(am *models.SubEvent) (*models.SubEvent, error) {
 	if err := repo.db.Create(am).Error; err != nil {
 		return nil, err
 	}
 	return am, nil
 }
 
-func (repo EventRepository) ReadEventsByContainerID(id uint) ([]*models.SubEvent, error) {
+func (repo BuildEventRepository) ReadEventsByContainerID(id uint) ([]*models.SubEvent, error) {
 	var events []*models.SubEvent
 	if err := repo.db.Where("event_container_id = ?", id).Find(&events).Error; err != nil {
 		return nil, err
@@ -39,7 +44,7 @@ func (repo EventRepository) ReadEventsByContainerID(id uint) ([]*models.SubEvent
 	return events, nil
 }
 
-func (repo EventRepository) ReadEventContainer(id uint) (*models.EventContainer, error) {
+func (repo BuildEventRepository) ReadEventContainer(id uint) (*models.EventContainer, error) {
 	container := &models.EventContainer{}
 	if err := repo.db.Where("id = ?", id).First(&container).Error; err != nil {
 		return nil, err
@@ -47,7 +52,7 @@ func (repo EventRepository) ReadEventContainer(id uint) (*models.EventContainer,
 	return container, nil
 }
 
-func (repo EventRepository) ReadSubEvent(id uint) (*models.SubEvent, error) {
+func (repo BuildEventRepository) ReadSubEvent(id uint) (*models.SubEvent, error) {
 	event := &models.SubEvent{}
 	if err := repo.db.Where("id = ?", id).First(&event).Error; err != nil {
 		return nil, err
@@ -57,7 +62,237 @@ func (repo EventRepository) ReadSubEvent(id uint) (*models.SubEvent, error) {
 
 // AppendEvent will check if subevent with same (id, index) already exists
 // if yes, overrite it, otherwise make a new subevent
-func (repo EventRepository) AppendEvent(container *models.EventContainer, event *models.SubEvent) error {
+func (repo BuildEventRepository) AppendEvent(container *models.EventContainer, event *models.SubEvent) error {
 	event.EventContainerID = container.ID
 	return repo.db.Create(event).Error
 }
+
+// KubeEventRepository uses gorm.DB for querying the database
+type KubeEventRepository struct {
+	db  *gorm.DB
+	key *[32]byte
+}
+
+// NewKubeEventRepository returns an KubeEventRepository which uses
+// gorm.DB for querying the database. It accepts an encryption key to encrypt
+// sensitive data
+func NewKubeEventRepository(db *gorm.DB, key *[32]byte) repository.KubeEventRepository {
+	return &KubeEventRepository{db, key}
+}
+
+// CreateEvent creates a new kube auth mechanism
+func (repo *KubeEventRepository) CreateEvent(
+	event *models.KubeEvent,
+) (*models.KubeEvent, error) {
+	// read the count of the events in the DB
+	query := repo.db.Where("project_id = ? AND cluster_id = ?", event.ProjectID, event.ClusterID)
+
+	var count int64
+
+	if err := query.Model([]*models.KubeEvent{}).Count(&count).Error; err != nil {
+		return nil, err
+	}
+
+	fmt.Println("COUNT IS", event.Name, count)
+
+	// if the count is greater than 500, remove the lowest-order event to implement a
+	// basic fixed-length buffer
+	if count >= 500 {
+		// first, delete the matching sub events
+		err := repo.db.Exec(`
+		  DELETE FROM kube_sub_events 
+		  WHERE kube_event_id NOT IN (
+			SELECT id FROM kube_events k2 WHERE (k2.project_id = ? AND k2.cluster_id = ?) ORDER BY updated_at desc, id desc LIMIT 499
+		  )
+		`, event.ProjectID, event.ClusterID).Error
+
+		if err != nil {
+			return nil, err
+		}
+
+		// then, delete the matching events
+		err = repo.db.Exec(`
+		  DELETE FROM kube_events 
+		  WHERE (project_id = ? AND cluster_id = ?) AND id NOT IN (
+			SELECT id FROM kube_events k2 WHERE (k2.project_id = ? AND k2.cluster_id = ?) ORDER BY updated_at desc, id desc LIMIT 499
+		  )
+		`, event.ProjectID, event.ClusterID, event.ProjectID, event.ClusterID).Error
+
+		if err != nil {
+			return nil, err
+		}
+	}
+
+	if err := repo.db.Create(event).Error; err != nil {
+		return nil, err
+	}
+
+	return event, nil
+}
+
+// ReadEvent finds an event by id
+func (repo *KubeEventRepository) ReadEvent(
+	id, projID, clusterID uint,
+) (*models.KubeEvent, error) {
+	event := &models.KubeEvent{}
+
+	if err := repo.db.Preload("SubEvents").Where(
+		"id = ? AND project_id = ? AND cluster_id = ?",
+		id,
+		projID,
+		clusterID,
+	).First(&event).Error; err != nil {
+		return nil, err
+	}
+
+	return event, nil
+}
+
+// ReadEventByGroup finds an event by a set of options which group events together
+func (repo *KubeEventRepository) ReadEventByGroup(
+	projID uint,
+	clusterID uint,
+	opts *types.GroupOptions,
+) (*models.KubeEvent, error) {
+	event := &models.KubeEvent{}
+
+	query := repo.db.Preload("SubEvents").
+		Where("project_id = ? AND cluster_id = ? AND name = ? AND LOWER(resource_type) = LOWER(?)", projID, clusterID, opts.Name, opts.ResourceType)
+
+	// construct query for timestamp
+	query = query.Where(
+		"updated_at >= ?", opts.ThresholdTime,
+	)
+
+	if opts.Namespace != "" {
+		query = query.Where(
+			"namespace = ?",
+			strings.ToLower(opts.Namespace),
+		)
+	}
+
+	if err := query.First(&event).Error; err != nil {
+		return nil, err
+	}
+
+	return event, nil
+}
+
+// ListEventsByProjectID finds all events for a given project id
+// with the given options
+func (repo *KubeEventRepository) ListEventsByProjectID(
+	projectID uint,
+	clusterID uint,
+	opts *types.ListKubeEventRequest,
+) ([]*models.KubeEvent, int64, error) {
+	listOpts := opts
+
+	if listOpts.Limit == 0 {
+		listOpts.Limit = 50
+	}
+
+	events := []*models.KubeEvent{}
+
+	// preload the subevents
+	query := repo.db.Preload("SubEvents").Where("project_id = ? AND cluster_id = ?", projectID, clusterID)
+
+	if listOpts.OwnerName != "" && listOpts.OwnerType != "" {
+		query = query.Where(
+			"LOWER(owner_name) = LOWER(?) AND LOWER(owner_type) = LOWER(?)",
+			listOpts.OwnerName,
+			listOpts.OwnerType,
+		)
+	}
+
+	if listOpts.ResourceType != "" {
+		query = query.Where(
+			"LOWER(resource_type) = LOWER(?)",
+			listOpts.ResourceType,
+		)
+	}
+
+	// get the count before limit and offset
+	var count int64
+
+	if err := query.Model([]*models.KubeEvent{}).Count(&count).Error; err != nil {
+		return nil, 0, err
+	}
+
+	query = query.Order("updated_at desc").Order("id desc").Limit(listOpts.Limit).Offset(listOpts.Skip)
+
+	if err := query.Find(&events).Error; err != nil {
+		return nil, 0, err
+	}
+
+	return events, count, nil
+}
+
+// AppendSubEvent will add a subevent to an existing event
+func (repo *KubeEventRepository) AppendSubEvent(event *models.KubeEvent, subEvent *models.KubeSubEvent) error {
+	subEvent.KubeEventID = event.ID
+
+	var count int64
+
+	query := repo.db.Where("kube_event_id = ?", event.ID)
+
+	if err := query.Model([]*models.KubeSubEvent{}).Count(&count).Error; err != nil {
+		return err
+	}
+
+	fmt.Println("COUNT IS", event.Name, count)
+
+	// if the count is greater than 20, remove the lowest-order events to implement a
+	// basic fixed-length buffer
+	if count >= 20 {
+		err := repo.db.Exec(`
+			  DELETE FROM kube_sub_events 
+			  WHERE kube_event_id = ? AND 
+			  id NOT IN (
+				SELECT id FROM kube_sub_events k2 WHERE k2.kube_event_id = ? ORDER BY updated_at desc, id desc LIMIT 19
+			  )
+			`, event.ID, event.ID).Error
+
+		if err != nil {
+			return err
+		}
+	}
+
+	// we construct a shallow copy here that just populates the primary key, because otherwise gorm
+	// attempts to write subevents that have already been written via the association.
+	shallowCopy := &models.KubeEvent{
+		Model: gorm.Model{
+			ID: event.ID,
+		},
+	}
+
+	if err := repo.db.Model(shallowCopy).Association("SubEvents").Append(subEvent); err != nil {
+		return err
+	}
+
+	// only update the updated_at field for the event
+	if err := repo.db.Model(shallowCopy).Update("updated_at", time.Now()).Error; err != nil {
+		return err
+	}
+
+	event.SubEvents = append(event.SubEvents, shallowCopy.SubEvents...)
+	event.UpdatedAt = shallowCopy.UpdatedAt
+
+	return nil
+}
+
+// DeleteEvent deletes an event by ID
+func (repo *KubeEventRepository) DeleteEvent(
+	id uint,
+) error {
+	return deleteEventPermanently(id, repo.db)
+}
+
+func deleteEventPermanently(id uint, db *gorm.DB) error {
+	// delete all subevents first
+	if err := db.Unscoped().Where("kube_event_id = ?", id).Delete(&models.KubeSubEvent{}).Error; err != nil {
+		return err
+	}
+
+	// delete event
+	return db.Preload("SubEvents").Unscoped().Where("id = ?", id).Delete(&models.KubeEvent{}).Error
+}

+ 201 - 0
internal/repository/gorm/event_test.go

@@ -0,0 +1,201 @@
+package gorm_test
+
+import (
+	"fmt"
+	"testing"
+	"time"
+
+	"github.com/go-test/deep"
+	"github.com/porter-dev/porter/api/types"
+	"github.com/porter-dev/porter/internal/models"
+	"github.com/porter-dev/porter/internal/repository"
+	"gorm.io/gorm"
+)
+
+func TestCreateKubeEvent(t *testing.T) {
+	tester := &tester{
+		dbFileName: "./porter_create_event.db",
+	}
+
+	setupTestEnv(tester, t)
+	initProject(tester, t)
+	initCluster(tester, t)
+	defer cleanup(tester, t)
+
+	event := &models.KubeEvent{
+		ProjectID: tester.initProjects[0].Model.ID,
+		ClusterID: tester.initClusters[0].Model.ID,
+		Name:      "pod-example-1",
+		Namespace: "default",
+	}
+
+	copyKubeEvent := *event
+
+	event, err := tester.repo.KubeEvent().CreateEvent(event)
+
+	if err != nil {
+		t.Fatalf("%v\n", err)
+	}
+
+	// append a sub event as well
+	subEvent := &models.KubeSubEvent{
+		EventType: "pod",
+		Message:   "Pod killed",
+		Reason:    "OOM: memory limit exceeded",
+		Timestamp: time.Now(),
+	}
+
+	copySubEvent := *subEvent
+	copySubEvent.KubeEventID = 1
+
+	err = tester.repo.KubeEvent().AppendSubEvent(event, subEvent)
+
+	if err != nil {
+		t.Fatalf("%v\n", err)
+	}
+
+	copyKubeEvent.SubEvents = []models.KubeSubEvent{copySubEvent}
+
+	event, err = tester.repo.KubeEvent().ReadEvent(event.Model.ID, 1, 1)
+
+	if err != nil {
+		t.Fatalf("%v\n", err)
+	}
+
+	// make sure id is 1 and name is "ecr"
+	if event.Model.ID != 1 {
+		t.Errorf("incorrect registry ID: expected %d, got %d\n", 1, event.Model.ID)
+	}
+
+	event.Model = gorm.Model{}
+	event.SubEvents[0].Model = gorm.Model{}
+
+	if diff := deep.Equal(event, &copyKubeEvent); diff != nil {
+		t.Errorf("events not equal:")
+		t.Error(diff)
+	}
+}
+
+func TestReadKubeEventsByGroup(t *testing.T) {
+	suffix, _ := repository.GenerateRandomBytes(4)
+
+	tester := &tester{
+		dbFileName: fmt.Sprintf("./porter_read_event_%s.db", suffix),
+	}
+
+	setupTestEnv(tester, t)
+	initProject(tester, t)
+	initCluster(tester, t)
+	initKubeEvents(tester, t)
+	defer cleanup(tester, t)
+
+	event, err := tester.repo.KubeEvent().ReadEventByGroup(
+		tester.initProjects[0].Model.ID,
+		tester.initClusters[0].Model.ID,
+		&types.GroupOptions{
+			Name:          "pod-example-1",
+			Namespace:     "default",
+			ResourceType:  "pod",
+			ThresholdTime: time.Now().Add(-15 * time.Minute),
+		},
+	)
+
+	if err != nil {
+		t.Fatalf("%v\n", err)
+	}
+
+	expKubeEvent := tester.initKubeEvents[1]
+
+	if diff := deep.Equal(expKubeEvent, event); diff != nil {
+		t.Errorf("incorrect events")
+		t.Error(diff)
+	}
+}
+
+func TestListKubeEventsByProjectIDWithLimit(t *testing.T) {
+	suffix, _ := repository.GenerateRandomBytes(4)
+
+	tester := &tester{
+		dbFileName: fmt.Sprintf("./porter_list_events_%s.db", suffix),
+	}
+
+	setupTestEnv(tester, t)
+	initProject(tester, t)
+	initCluster(tester, t)
+	initKubeEvents(tester, t)
+	defer cleanup(tester, t)
+
+	testListKubeEventsByProjectID(tester, t, 1, true, &types.ListKubeEventRequest{
+		Limit:        10,
+		ResourceType: "node",
+	}, tester.initKubeEvents[50:60])
+}
+
+func TestListKubeEventsByProjectIDWithSkip(t *testing.T) {
+	suffix, _ := repository.GenerateRandomBytes(4)
+
+	tester := &tester{
+		dbFileName: fmt.Sprintf("./porter_list_events_%s.db", suffix),
+	}
+
+	setupTestEnv(tester, t)
+	initProject(tester, t)
+	initCluster(tester, t)
+	initKubeEvents(tester, t)
+	defer cleanup(tester, t)
+
+	testListKubeEventsByProjectID(tester, t, 1, true, &types.ListKubeEventRequest{
+		Limit: 25,
+		Skip:  10,
+	}, tester.initKubeEvents[10:35])
+}
+
+func TestDeleteKubeEvents(t *testing.T) {
+	suffix, _ := repository.GenerateRandomBytes(4)
+
+	tester := &tester{
+		dbFileName: fmt.Sprintf("./porter_delete_events_%s.db", suffix),
+	}
+
+	setupTestEnv(tester, t)
+	initProject(tester, t)
+	initCluster(tester, t)
+	initKubeEvents(tester, t)
+	defer cleanup(tester, t)
+
+	// delete a specific event and then test list again
+	err := tester.repo.KubeEvent().DeleteEvent(11)
+
+	if err != nil {
+		t.Fatalf("%v\n", err)
+	}
+
+	testListKubeEventsByProjectID(tester, t, 1, true, &types.ListKubeEventRequest{
+		Limit: 25,
+		Skip:  10,
+	}, tester.initKubeEvents[11:36])
+}
+
+func testListKubeEventsByProjectID(tester *tester, t *testing.T, clusterID uint, decrypt bool, opts *types.ListKubeEventRequest, expKubeEvents []*models.KubeEvent) {
+	t.Helper()
+
+	events, _, err := tester.repo.KubeEvent().ListEventsByProjectID(
+		tester.initProjects[0].Model.ID,
+		clusterID,
+		opts,
+	)
+
+	if err != nil {
+		t.Fatalf("%v\n", err)
+	}
+
+	// make sure data is correct
+	if len(events) != len(expKubeEvents) {
+		t.Fatalf("length of events incorrect: expected %d, got %d\n", len(expKubeEvents), len(events))
+	}
+
+	if diff := deep.Equal(expKubeEvents, events); diff != nil {
+		t.Errorf("incorrect events")
+		t.Error(diff)
+	}
+}

+ 87 - 19
internal/repository/gorm/helpers_test.go

@@ -1,6 +1,7 @@
 package gorm_test
 
 import (
+	"fmt"
 	"os"
 	"testing"
 	"time"
@@ -15,25 +16,26 @@ import (
 )
 
 type tester struct {
-	repo         repository.Repository
-	key          *[32]byte
-	dbFileName   string
-	initUsers    []*models.User
-	initProjects []*models.Project
-	initGRs      []*models.GitRepo
-	initRegs     []*models.Registry
-	initClusters []*models.Cluster
-	initHRs      []*models.HelmRepo
-	initInfras   []*models.Infra
-	initReleases []*models.Release
-	initInvites  []*models.Invite
-	initCCs      []*models.ClusterCandidate
-	initKIs      []*ints.KubeIntegration
-	initBasics   []*ints.BasicIntegration
-	initOIDCs    []*ints.OIDCIntegration
-	initOAuths   []*ints.OAuthIntegration
-	initGCPs     []*ints.GCPIntegration
-	initAWSs     []*ints.AWSIntegration
+	repo           repository.Repository
+	key            *[32]byte
+	dbFileName     string
+	initUsers      []*models.User
+	initProjects   []*models.Project
+	initGRs        []*models.GitRepo
+	initRegs       []*models.Registry
+	initClusters   []*models.Cluster
+	initHRs        []*models.HelmRepo
+	initInfras     []*models.Infra
+	initReleases   []*models.Release
+	initInvites    []*models.Invite
+	initKubeEvents []*models.KubeEvent
+	initCCs        []*models.ClusterCandidate
+	initKIs        []*ints.KubeIntegration
+	initBasics     []*ints.BasicIntegration
+	initOIDCs      []*ints.OIDCIntegration
+	initOAuths     []*ints.OAuthIntegration
+	initGCPs       []*ints.GCPIntegration
+	initAWSs       []*ints.AWSIntegration
 }
 
 func setupTestEnv(tester *tester, t *testing.T) {
@@ -64,6 +66,8 @@ func setupTestEnv(tester *tester, t *testing.T) {
 		&models.Infra{},
 		&models.GitActionConfig{},
 		&models.Invite{},
+		&models.KubeEvent{},
+		&models.KubeSubEvent{},
 		&models.Onboarding{},
 		&ints.KubeIntegration{},
 		&ints.BasicIntegration{},
@@ -548,3 +552,67 @@ func initRelease(tester *tester, t *testing.T) {
 
 	tester.initReleases = append(tester.initReleases, release)
 }
+
+func initKubeEvents(tester *tester, t *testing.T) {
+	t.Helper()
+
+	if len(tester.initProjects) == 0 {
+		initProject(tester, t)
+	}
+
+	initEvents := make([]*models.KubeEvent, 0)
+
+	// init 100 events for testing purposes
+	for i := 0; i < 100; i++ {
+		refType := "pod"
+
+		if i >= 50 {
+			refType = "node"
+		}
+
+		event := &models.KubeEvent{
+			ProjectID:    tester.initProjects[0].Model.ID,
+			ClusterID:    tester.initClusters[0].Model.ID,
+			Name:         fmt.Sprintf("%s-example-%d", refType, i),
+			Namespace:    "default",
+			ResourceType: refType,
+		}
+
+		event, err := tester.repo.KubeEvent().CreateEvent(event)
+
+		if err != nil {
+			t.Fatalf("%v\n", err)
+		}
+
+		// append a sub event as well
+		subEvent := &models.KubeSubEvent{
+			EventType: "pod",
+			Message:   "Pod killed",
+			Reason:    "OOM: memory limit exceeded",
+		}
+
+		err = tester.repo.KubeEvent().AppendSubEvent(event, subEvent)
+
+		if err != nil {
+			t.Fatalf("%v\n", err)
+		}
+
+		initEvents = append(initEvents, event)
+	}
+
+	for i := 99; i >= 0; i-- {
+		subEvent := &models.KubeSubEvent{
+			EventType: "pod",
+			Message:   "Pod killed",
+			Reason:    "OOM: memory limit exceeded",
+		}
+
+		err := tester.repo.KubeEvent().AppendSubEvent(initEvents[i], subEvent)
+
+		if err != nil {
+			t.Fatalf("%v\n", err)
+		}
+	}
+
+	tester.initKubeEvents = initEvents
+}

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

@@ -29,6 +29,8 @@ func AutoMigrate(db *gorm.DB) error {
 		&models.NotificationConfig{},
 		&models.EventContainer{},
 		&models.SubEvent{},
+		&models.KubeEvent{},
+		&models.KubeSubEvent{},
 		&models.ProjectUsage{},
 		&models.ProjectUsageCache{},
 		&models.Onboarding{},

+ 10 - 4
internal/repository/gorm/repository.go

@@ -31,7 +31,8 @@ type GormRepository struct {
 	githubAppOAuthIntegration repository.GithubAppOAuthIntegrationRepository
 	slackIntegration          repository.SlackIntegrationRepository
 	notificationConfig        repository.NotificationConfigRepository
-	event                     repository.EventRepository
+	buildEvent                repository.BuildEventRepository
+	kubeEvent                 repository.KubeEventRepository
 	projectUsage              repository.ProjectUsageRepository
 	onboarding                repository.ProjectOnboardingRepository
 	ceToken                   repository.CredentialsExchangeTokenRepository
@@ -133,8 +134,12 @@ func (t *GormRepository) NotificationConfig() repository.NotificationConfigRepos
 	return t.notificationConfig
 }
 
-func (t *GormRepository) Event() repository.EventRepository {
-	return t.event
+func (t *GormRepository) BuildEvent() repository.BuildEventRepository {
+	return t.buildEvent
+}
+
+func (t *GormRepository) KubeEvent() repository.KubeEventRepository {
+	return t.kubeEvent
 }
 
 func (t *GormRepository) ProjectUsage() repository.ProjectUsageRepository {
@@ -177,7 +182,8 @@ func NewRepository(db *gorm.DB, key *[32]byte, storageBackend credentials.Creden
 		githubAppOAuthIntegration: NewGithubAppOAuthIntegrationRepository(db),
 		slackIntegration:          NewSlackIntegrationRepository(db, key),
 		notificationConfig:        NewNotificationConfigRepository(db),
-		event:                     NewEventRepository(db),
+		buildEvent:                NewBuildEventRepository(db),
+		kubeEvent:                 NewKubeEventRepository(db, key),
 		projectUsage:              NewProjectUsageRepository(db),
 		onboarding:                NewProjectOnboardingRepository(db),
 		ceToken:                   NewCredentialsExchangeTokenRepository(db),

+ 2 - 1
internal/repository/repository.go

@@ -25,7 +25,8 @@ type Repository interface {
 	GithubAppOAuthIntegration() GithubAppOAuthIntegrationRepository
 	SlackIntegration() SlackIntegrationRepository
 	NotificationConfig() NotificationConfigRepository
-	Event() EventRepository
+	BuildEvent() BuildEventRepository
+	KubeEvent() KubeEventRepository
 	ProjectUsage() ProjectUsageRepository
 	Onboarding() ProjectOnboardingRepository
 	CredentialsExchangeToken() CredentialsExchangeTokenRepository

+ 48 - 9
internal/repository/test/event.go

@@ -1,36 +1,75 @@
 package test
 
 import (
+	"github.com/porter-dev/porter/api/types"
 	"github.com/porter-dev/porter/internal/models"
 	"github.com/porter-dev/porter/internal/repository"
 )
 
-type EventRepository struct{}
+type BuildEventRepository struct{}
 
-func NewEventRepository(canQuery bool) repository.EventRepository {
-	return &EventRepository{}
+func NewBuildEventRepository(canQuery bool) repository.BuildEventRepository {
+	return &BuildEventRepository{}
 }
 
-func (n *EventRepository) CreateEventContainer(am *models.EventContainer) (*models.EventContainer, error) {
+func (n *BuildEventRepository) CreateEventContainer(am *models.EventContainer) (*models.EventContainer, error) {
 	panic("not implemented") // TODO: Implement
 }
 
-func (n *EventRepository) CreateSubEvent(am *models.SubEvent) (*models.SubEvent, error) {
+func (n *BuildEventRepository) CreateSubEvent(am *models.SubEvent) (*models.SubEvent, error) {
 	panic("not implemented") // TODO: Implement
 }
 
-func (n *EventRepository) ReadEventsByContainerID(id uint) ([]*models.SubEvent, error) {
+func (n *BuildEventRepository) ReadEventsByContainerID(id uint) ([]*models.SubEvent, error) {
 	panic("not implemented") // TODO: Implement
 }
 
-func (n *EventRepository) ReadEventContainer(id uint) (*models.EventContainer, error) {
+func (n *BuildEventRepository) ReadEventContainer(id uint) (*models.EventContainer, error) {
 	panic("not implemented") // TODO: Implement
 }
 
-func (n *EventRepository) ReadSubEvent(id uint) (*models.SubEvent, error) {
+func (n *BuildEventRepository) ReadSubEvent(id uint) (*models.SubEvent, error) {
 	panic("not implemented") // TODO: Implement
 }
 
-func (n *EventRepository) AppendEvent(container *models.EventContainer, event *models.SubEvent) error {
+func (n *BuildEventRepository) AppendEvent(container *models.EventContainer, event *models.SubEvent) error {
+	panic("not implemented") // TODO: Implement
+}
+
+type KubeEventRepository struct{}
+
+func NewKubeEventRepository(canQuery bool) repository.KubeEventRepository {
+	return &KubeEventRepository{}
+}
+
+func (n *KubeEventRepository) CreateEvent(event *models.KubeEvent) (*models.KubeEvent, error) {
+	panic("not implemented") // TODO: Implement
+}
+
+func (n *KubeEventRepository) ReadEvent(id uint, projID uint, clusterID uint) (*models.KubeEvent, error) {
+	panic("not implemented") // TODO: Implement
+}
+
+func (n *KubeEventRepository) ReadEventByGroup(
+	projID uint,
+	clusterID uint,
+	opts *types.GroupOptions,
+) (*models.KubeEvent, error) {
+	panic("not implemented") // TODO: Implement
+}
+
+func (n *KubeEventRepository) ListEventsByProjectID(
+	projectID uint,
+	clusterID uint,
+	opts *types.ListKubeEventRequest,
+) ([]*models.KubeEvent, int64, error) {
+	panic("not implemented") // TODO: Implement
+}
+
+func (n *KubeEventRepository) AppendSubEvent(event *models.KubeEvent, subEvent *models.KubeSubEvent) error {
+	panic("not implemented") // TODO: Implement
+}
+
+func (n *KubeEventRepository) DeleteEvent(id uint) error {
 	panic("not implemented") // TODO: Implement
 }

+ 10 - 4
internal/repository/test/repository.go

@@ -29,7 +29,8 @@ type TestRepository struct {
 	githubAppOAuthIntegration repository.GithubAppOAuthIntegrationRepository
 	slackIntegration          repository.SlackIntegrationRepository
 	notificationConfig        repository.NotificationConfigRepository
-	event                     repository.EventRepository
+	buildEvent                repository.BuildEventRepository
+	kubeEvent                 repository.KubeEventRepository
 	projectUsage              repository.ProjectUsageRepository
 	onboarding                repository.ProjectOnboardingRepository
 	ceToken                   repository.CredentialsExchangeTokenRepository
@@ -131,8 +132,12 @@ func (t *TestRepository) NotificationConfig() repository.NotificationConfigRepos
 	return t.notificationConfig
 }
 
-func (t *TestRepository) Event() repository.EventRepository {
-	return t.event
+func (t *TestRepository) BuildEvent() repository.BuildEventRepository {
+	return t.buildEvent
+}
+
+func (t *TestRepository) KubeEvent() repository.KubeEventRepository {
+	return t.kubeEvent
 }
 
 func (t *TestRepository) ProjectUsage() repository.ProjectUsageRepository {
@@ -175,7 +180,8 @@ func NewRepository(canQuery bool, failingMethods ...string) repository.Repositor
 		githubAppOAuthIntegration: NewGithubAppOAuthIntegrationRepository(canQuery),
 		slackIntegration:          NewSlackIntegrationRepository(canQuery),
 		notificationConfig:        NewNotificationConfigRepository(canQuery),
-		event:                     NewEventRepository(canQuery),
+		buildEvent:                NewBuildEventRepository(canQuery),
+		kubeEvent:                 NewKubeEventRepository(canQuery),
 		projectUsage:              NewProjectUsageRepository(canQuery),
 		onboarding:                NewProjectOnboardingRepository(canQuery),
 		ceToken:                   NewCredentialsExchangeTokenRepository(canQuery),