ソースを参照

Merge pull request #1760 from porter-dev/nafees/porter-agent-v2-endpoints

[POR-368] API endpoints for incidents
abelanger5 4 年 前
コミット
8528c1c180
28 ファイル変更2815 行追加62 行削除
  1. 17 4
      api/server/handlers/cluster/detect_agent_installed.go
  2. 64 0
      api/server/handlers/cluster/get_incident_event_logs.go
  3. 94 0
      api/server/handlers/cluster/get_incidents.go
  4. 89 0
      api/server/handlers/cluster/notify_new_incident.go
  5. 89 0
      api/server/handlers/cluster/notify_resolved_incident.go
  6. 81 0
      api/server/handlers/cluster/upgrade_agent.go
  7. 146 1
      api/server/router/cluster.go
  8. 5 0
      api/types/agent.go
  9. 14 0
      api/types/cluster.go
  10. 98 16
      dashboard/src/components/Table.tsx
  11. 17 5
      dashboard/src/components/expanded-object/Header.tsx
  12. 1 1
      dashboard/src/main/home/cluster-dashboard/chart/JobRunTable.tsx
  13. 5 5
      dashboard/src/main/home/cluster-dashboard/dashboard/Dashboard.tsx
  14. 4 0
      dashboard/src/main/home/cluster-dashboard/dashboard/Routes.tsx
  15. 233 0
      dashboard/src/main/home/cluster-dashboard/dashboard/incidents/EventDrawer.tsx
  16. 62 0
      dashboard/src/main/home/cluster-dashboard/dashboard/incidents/ExpandedContainer.tsx
  17. 524 0
      dashboard/src/main/home/cluster-dashboard/dashboard/incidents/IncidentPage.tsx
  18. 215 0
      dashboard/src/main/home/cluster-dashboard/dashboard/incidents/IncidentsTab.tsx
  19. 209 0
      dashboard/src/main/home/cluster-dashboard/dashboard/incidents/IncidentsTable.tsx
  20. 9 5
      dashboard/src/main/home/cluster-dashboard/expanded-chart/ExpandedChart.tsx
  21. 217 0
      dashboard/src/main/home/cluster-dashboard/expanded-chart/incidents/IncidentsTab.tsx
  22. 217 0
      dashboard/src/main/home/cluster-dashboard/expanded-chart/incidents/IncidentsTable.tsx
  23. 82 20
      dashboard/src/shared/api.tsx
  24. 1 1
      go.mod
  25. 0 4
      go.sum
  26. 135 0
      internal/integrations/slack/incidents_notifier.go
  27. 132 0
      internal/kubernetes/porter_agent/v2/agent_server.go
  28. 55 0
      internal/kubernetes/porter_agent/v2/models.go

+ 17 - 4
api/server/handlers/cluster/detect_agent_installed.go

@@ -6,6 +6,7 @@ import (
 
 	"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"
@@ -14,15 +15,16 @@ import (
 )
 
 type DetectAgentInstalledHandler struct {
-	handlers.PorterHandler
+	handlers.PorterHandlerWriter
 	authz.KubernetesAgentGetter
 }
 
 func NewDetectAgentInstalledHandler(
 	config *config.Config,
+	writer shared.ResultWriter,
 ) *DetectAgentInstalledHandler {
 	return &DetectAgentInstalledHandler{
-		PorterHandler:         handlers.NewDefaultPorterHandler(config, nil, nil),
+		PorterHandlerWriter:   handlers.NewDefaultPorterHandler(config, nil, writer),
 		KubernetesAgentGetter: authz.NewOutOfClusterAgentGetter(config),
 	}
 }
@@ -37,7 +39,7 @@ func (c *DetectAgentInstalledHandler) ServeHTTP(w http.ResponseWriter, r *http.R
 		return
 	}
 
-	_, err = agent.GetPorterAgent()
+	depl, err := agent.GetPorterAgent()
 
 	if targetErr := kubernetes.IsNotFoundError; err != nil && errors.Is(err, targetErr) {
 		http.NotFound(w, r)
@@ -47,5 +49,16 @@ func (c *DetectAgentInstalledHandler) ServeHTTP(w http.ResponseWriter, r *http.R
 		return
 	}
 
-	w.WriteHeader(http.StatusOK)
+	// detect the version of the agent which is installed
+	res := &types.GetAgentResponse{}
+
+	versionAnn, ok := depl.ObjectMeta.Annotations["porter.run/agent-major-version"]
+
+	if !ok {
+		res.Version = "v1"
+	} else {
+		res.Version = versionAnn
+	}
+
+	c.WriteResult(w, r, res)
 }

+ 64 - 0
api/server/handlers/cluster/get_incident_event_logs.go

@@ -0,0 +1,64 @@
+package cluster
+
+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"
+	porter_agent "github.com/porter-dev/porter/internal/kubernetes/porter_agent/v2"
+	"github.com/porter-dev/porter/internal/models"
+)
+
+type GetIncidentEventLogsHandler struct {
+	handlers.PorterHandlerReadWriter
+	authz.KubernetesAgentGetter
+}
+
+func NewGetIncidentEventLogsHandler(
+	config *config.Config,
+	decoderValidator shared.RequestDecoderValidator,
+	writer shared.ResultWriter,
+) *GetIncidentEventLogsHandler {
+	return &GetIncidentEventLogsHandler{
+		PorterHandlerReadWriter: handlers.NewDefaultPorterHandler(config, decoderValidator, writer),
+		KubernetesAgentGetter:   authz.NewOutOfClusterAgentGetter(config),
+	}
+}
+
+func (c *GetIncidentEventLogsHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	cluster, _ := r.Context().Value(types.ClusterScope).(*models.Cluster)
+
+	request := &types.GetIncidentEventLogsRequest{}
+
+	if ok := c.DecodeAndValidate(w, r, request); !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
+	}
+
+	logs, err := porter_agent.GetLogs(agent.Clientset, agentSvc, request.LogID)
+
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	c.WriteResult(w, r, logs)
+}

+ 94 - 0
api/server/handlers/cluster/get_incidents.go

@@ -0,0 +1,94 @@
+package cluster
+
+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"
+	porter_agent "github.com/porter-dev/porter/internal/kubernetes/porter_agent/v2"
+	"github.com/porter-dev/porter/internal/models"
+)
+
+type GetIncidentsHandler struct {
+	handlers.PorterHandlerReadWriter
+	authz.KubernetesAgentGetter
+}
+
+func NewGetIncidentsHandler(
+	config *config.Config,
+	decoderValidator shared.RequestDecoderValidator,
+	writer shared.ResultWriter,
+) *GetIncidentsHandler {
+	return &GetIncidentsHandler{
+		PorterHandlerReadWriter: handlers.NewDefaultPorterHandler(config, decoderValidator, writer),
+		KubernetesAgentGetter:   authz.NewOutOfClusterAgentGetter(config),
+	}
+}
+
+func (c *GetIncidentsHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	cluster, _ := r.Context().Value(types.ClusterScope).(*models.Cluster)
+
+	request := &types.GetIncidentsRequest{}
+
+	if ok := c.DecodeAndValidate(w, r, request); !ok {
+		return
+	}
+
+	incidentID := request.IncidentID
+	releaseName := request.ReleaseName
+	namespace := request.Namespace
+
+	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
+	}
+
+	if incidentID != "" {
+		events, err := porter_agent.GetIncidentEventsByID(agent.Clientset, agentSvc, incidentID)
+
+		if err != nil {
+			c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+			return
+		}
+
+		c.WriteResult(w, r, events)
+		return
+	} else if releaseName != "" {
+		if namespace == "" {
+			namespace = "default"
+		}
+
+		incidents, err := porter_agent.GetIncidentsByReleaseNamespace(agent.Clientset, agentSvc, releaseName, namespace)
+
+		if err != nil {
+			c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+			return
+		}
+
+		c.WriteResult(w, r, incidents)
+		return
+	}
+
+	incidents, err := porter_agent.GetAllIncidents(agent.Clientset, agentSvc)
+
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	c.WriteResult(w, r, incidents)
+}

+ 89 - 0
api/server/handlers/cluster/notify_new_incident.go

@@ -0,0 +1,89 @@
+package cluster
+
+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/types"
+	"github.com/porter-dev/porter/internal/integrations/slack"
+	porter_agent "github.com/porter-dev/porter/internal/kubernetes/porter_agent/v2"
+	"github.com/porter-dev/porter/internal/models"
+)
+
+type NotifyNewIncidentHandler struct {
+	handlers.PorterHandlerReadWriter
+	authz.KubernetesAgentGetter
+}
+
+func NewNotifyNewIncidentHandler(
+	config *config.Config,
+	decoderValidator shared.RequestDecoderValidator,
+	writer shared.ResultWriter,
+) *NotifyNewIncidentHandler {
+	return &NotifyNewIncidentHandler{
+		PorterHandlerReadWriter: handlers.NewDefaultPorterHandler(config, decoderValidator, writer),
+		KubernetesAgentGetter:   authz.NewOutOfClusterAgentGetter(config),
+	}
+}
+
+func (c *NotifyNewIncidentHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	cluster, _ := r.Context().Value(types.ClusterScope).(*models.Cluster)
+
+	request := &porter_agent.Incident{}
+
+	if ok := c.DecodeAndValidate(w, r, request); !ok {
+		return
+	}
+
+	// FIXME: better error detection for correct incident ID
+	segments := strings.Split(request.ID, ":")
+	if len(segments) != 4 || (len(segments) > 0 && segments[0] != "incident") {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(fmt.Errorf("invalid incident ID: %s", request.ID)))
+		return
+	}
+
+	slackInts, _ := c.Repo().SlackIntegration().ListSlackIntegrationsByProjectID(cluster.ProjectID)
+
+	rel, err := c.Repo().Release().ReadRelease(cluster.ID, segments[1], segments[2])
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	var notifConf *types.NotificationConfig
+
+	if rel != nil && rel.NotificationConfig != 0 {
+		conf, err := c.Repo().NotificationConfig().ReadNotificationConfig(rel.NotificationConfig)
+
+		if err != nil {
+			c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+			return
+		}
+
+		notifConf = conf.ToNotificationConfigType()
+	}
+
+	notifier := slack.NewIncidentsNotifier(notifConf, slackInts...)
+
+	if !cluster.NotificationsDisabled {
+		err := notifier.NotifyNew(
+			request, fmt.Sprintf(
+				"%s/cluster-dashboard/incidents/%s?namespace=%s",
+				c.Config().ServerConf.ServerURL,
+				request.ID,
+				segments[2],
+			),
+		)
+
+		if err != nil {
+			c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+			return
+		}
+	}
+}

+ 89 - 0
api/server/handlers/cluster/notify_resolved_incident.go

@@ -0,0 +1,89 @@
+package cluster
+
+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/types"
+	"github.com/porter-dev/porter/internal/integrations/slack"
+	porter_agent "github.com/porter-dev/porter/internal/kubernetes/porter_agent/v2"
+	"github.com/porter-dev/porter/internal/models"
+)
+
+type NotifyResolvedIncidentHandler struct {
+	handlers.PorterHandlerReadWriter
+	authz.KubernetesAgentGetter
+}
+
+func NewNotifyResolvedIncidentHandler(
+	config *config.Config,
+	decoderValidator shared.RequestDecoderValidator,
+	writer shared.ResultWriter,
+) *NotifyResolvedIncidentHandler {
+	return &NotifyResolvedIncidentHandler{
+		PorterHandlerReadWriter: handlers.NewDefaultPorterHandler(config, decoderValidator, writer),
+		KubernetesAgentGetter:   authz.NewOutOfClusterAgentGetter(config),
+	}
+}
+
+func (c *NotifyResolvedIncidentHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	cluster, _ := r.Context().Value(types.ClusterScope).(*models.Cluster)
+
+	request := &porter_agent.Incident{}
+
+	if ok := c.DecodeAndValidate(w, r, request); !ok {
+		return
+	}
+
+	// FIXME: better error detection for correct incident ID
+	segments := strings.Split(request.ID, ":")
+	if len(segments) != 4 || (len(segments) > 0 && segments[0] != "incident") {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(fmt.Errorf("invalid incident ID: %s", request.ID)))
+		return
+	}
+
+	slackInts, _ := c.Repo().SlackIntegration().ListSlackIntegrationsByProjectID(cluster.ProjectID)
+
+	rel, err := c.Repo().Release().ReadRelease(cluster.ID, segments[1], segments[2])
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	var notifConf *types.NotificationConfig
+
+	if rel != nil && rel.NotificationConfig != 0 {
+		conf, err := c.Repo().NotificationConfig().ReadNotificationConfig(rel.NotificationConfig)
+
+		if err != nil {
+			c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+			return
+		}
+
+		notifConf = conf.ToNotificationConfigType()
+	}
+
+	notifier := slack.NewIncidentsNotifier(notifConf, slackInts...)
+
+	if !cluster.NotificationsDisabled {
+		err := notifier.NotifyResolved(
+			request, fmt.Sprintf(
+				"%s/cluster-dashboard/incidents/%s?namespace=%s",
+				c.Config().ServerConf.ServerURL,
+				request.ID,
+				segments[2],
+			),
+		)
+
+		if err != nil {
+			c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+			return
+		}
+	}
+}

+ 81 - 0
api/server/handlers/cluster/upgrade_agent.go

@@ -0,0 +1,81 @@
+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/helm"
+	"github.com/porter-dev/porter/internal/helm/loader"
+	"github.com/porter-dev/porter/internal/models"
+)
+
+type UpgradeAgentHandler struct {
+	handlers.PorterHandlerReadWriter
+	authz.KubernetesAgentGetter
+}
+
+func NewUpgradeAgentHandler(
+	config *config.Config,
+	decoderValidator shared.RequestDecoderValidator,
+	writer shared.ResultWriter,
+) *UpgradeAgentHandler {
+	return &UpgradeAgentHandler{
+		PorterHandlerReadWriter: handlers.NewDefaultPorterHandler(config, decoderValidator, writer),
+		KubernetesAgentGetter:   authz.NewOutOfClusterAgentGetter(config),
+	}
+}
+
+func (c *UpgradeAgentHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	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
+	}
+
+	currRelease, err := helmAgent.GetRelease("porter-agent", 0, false)
+
+	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
+	}
+
+	newValues := currRelease.Config
+
+	// TODO: update values
+	// newValues["redis"] =
+
+	_, err = helmAgent.UpgradeReleaseByValues(&helm.UpgradeReleaseConfig{
+		Chart:      chart,
+		Name:       "porter-agent",
+		Values:     newValues,
+		Cluster:    cluster,
+		Repo:       c.Repo(),
+		Registries: []*models.Registry{},
+	}, c.Config().DOConf)
+
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(
+			fmt.Errorf("error upgrading the chart: %s", err.Error()),
+			http.StatusBadRequest,
+		))
+
+		return
+	}
+
+	w.WriteHeader(http.StatusOK)
+}

+ 146 - 1
api/server/router/cluster.go

@@ -585,7 +585,7 @@ func getClusterRoutes(
 		},
 	)
 
-	detectAgentInstalledHandler := cluster.NewDetectAgentInstalledHandler(config)
+	detectAgentInstalledHandler := cluster.NewDetectAgentInstalledHandler(config, factory.GetResultWriter())
 
 	routes = append(routes, &Route{
 		Endpoint: detectAgentInstalledEndpoint,
@@ -622,6 +622,35 @@ func getClusterRoutes(
 		Router:   r,
 	})
 
+	// POST /api/projects/{project_id}/clusters/{cluster_id}/agent/upgrade -> cluster.NewInstallAgentHandler
+	upgradeAgentEndpoint := factory.NewAPIEndpoint(
+		&types.APIRequestMetadata{
+			Verb:   types.APIVerbCreate,
+			Method: types.HTTPVerbPost,
+			Path: &types.Path{
+				Parent:       basePath,
+				RelativePath: relPath + "/agent/upgrade",
+			},
+			Scopes: []types.PermissionScope{
+				types.UserScope,
+				types.ProjectScope,
+				types.ClusterScope,
+			},
+		},
+	)
+
+	upgradeAgentHandler := cluster.NewUpgradeAgentHandler(
+		config,
+		factory.GetDecoderValidator(),
+		factory.GetResultWriter(),
+	)
+
+	routes = append(routes, &Route{
+		Endpoint: upgradeAgentEndpoint,
+		Handler:  upgradeAgentHandler,
+		Router:   r,
+	})
+
 	// GET /api/projects/{project_id}/clusters/{cluster_id}/kube_events -> kube_events.NewGetKubeEventHandler
 	listKubeEventsEndpoint := factory.NewAPIEndpoint(
 		&types.APIRequestMetadata{
@@ -917,5 +946,121 @@ func getClusterRoutes(
 		Router:   r,
 	})
 
+	// GET /api/projects/{project_id}/clusters/{cluster_id}/incidents -> cluster.NewGetIncidentsHandler
+	getIncidentsEndpoint := factory.NewAPIEndpoint(
+		&types.APIRequestMetadata{
+			Verb:   types.APIVerbGet,
+			Method: types.HTTPVerbGet,
+			Path: &types.Path{
+				Parent:       basePath,
+				RelativePath: relPath + "/incidents",
+			},
+			Scopes: []types.PermissionScope{
+				types.UserScope,
+				types.ProjectScope,
+				types.ClusterScope,
+			},
+		},
+	)
+
+	getIncidentsHandler := cluster.NewGetIncidentsHandler(
+		config,
+		factory.GetDecoderValidator(),
+		factory.GetResultWriter(),
+	)
+
+	routes = append(routes, &Route{
+		Endpoint: getIncidentsEndpoint,
+		Handler:  getIncidentsHandler,
+		Router:   r,
+	})
+
+	// GET /api/projects/{project_id}/clusters/{cluster_id}/incidents/logs -> cluster.NewGetIncidentsHandler
+	getIncidentEventLogsEndpoint := factory.NewAPIEndpoint(
+		&types.APIRequestMetadata{
+			Verb:   types.APIVerbGet,
+			Method: types.HTTPVerbGet,
+			Path: &types.Path{
+				Parent:       basePath,
+				RelativePath: relPath + "/incidents/logs",
+			},
+			Scopes: []types.PermissionScope{
+				types.UserScope,
+				types.ProjectScope,
+				types.ClusterScope,
+			},
+		},
+	)
+
+	getIncidentEventLogsHandler := cluster.NewGetIncidentEventLogsHandler(
+		config,
+		factory.GetDecoderValidator(),
+		factory.GetResultWriter(),
+	)
+
+	routes = append(routes, &Route{
+		Endpoint: getIncidentEventLogsEndpoint,
+		Handler:  getIncidentEventLogsHandler,
+		Router:   r,
+	})
+
+	// POST /api/projects/{project_id}/clusters/{cluster_id}/incidents/notify_new -> cluster.NewNotifyNewIncidentHandler
+	notifyNewIncidentEndpoint := factory.NewAPIEndpoint(
+		&types.APIRequestMetadata{
+			Verb:   types.APIVerbCreate,
+			Method: types.HTTPVerbPost,
+			Path: &types.Path{
+				Parent:       basePath,
+				RelativePath: relPath + "/incidents/notify_new",
+			},
+			Scopes: []types.PermissionScope{
+				types.UserScope,
+				types.ProjectScope,
+				types.ClusterScope,
+			},
+		},
+	)
+
+	notifyNewIncidentHandler := cluster.NewNotifyNewIncidentHandler(
+		config,
+		factory.GetDecoderValidator(),
+		factory.GetResultWriter(),
+	)
+
+	routes = append(routes, &Route{
+		Endpoint: notifyNewIncidentEndpoint,
+		Handler:  notifyNewIncidentHandler,
+		Router:   r,
+	})
+
+	// POST /api/projects/{project_id}/clusters/{cluster_id}/incidents/notify_resolved -> cluster.NewNotifyResolvedIncidentHandler
+	notifyResolvedIncidentEndpoint := factory.NewAPIEndpoint(
+		&types.APIRequestMetadata{
+			Verb:   types.APIVerbCreate,
+			Method: types.HTTPVerbPost,
+			Path: &types.Path{
+				Parent:       basePath,
+				RelativePath: relPath + "/incidents/notify_resolved",
+			},
+			Scopes: []types.PermissionScope{
+				types.UserScope,
+				types.ProjectScope,
+				types.ClusterScope,
+			},
+		},
+	)
+
+	notifyResolvedIncidentHandler := cluster.NewNotifyResolvedIncidentHandler(
+		config,
+		factory.GetDecoderValidator(),
+		factory.GetResultWriter(),
+	)
+
+	routes = append(routes, &Route{
+		Endpoint: notifyResolvedIncidentEndpoint,
+		Handler:  notifyResolvedIncidentHandler,
+		Router:   r,
+	})
+
 	return routes, newPath
 }

+ 5 - 0
api/types/agent.go

@@ -0,0 +1,5 @@
+package types
+
+type GetAgentResponse struct {
+	Version string `json:"version"`
+}

+ 14 - 0
api/types/cluster.go

@@ -247,3 +247,17 @@ type ListClusterResponse []*Cluster
 type CreateClusterCandidateResponse []*ClusterCandidate
 
 type ListClusterCandidateResponse []*ClusterCandidate
+
+type GetIncidentsRequest struct {
+	IncidentID  string `schema:"incident_id"`
+	ReleaseName string `schema:"release_name"`
+	Namespace   string `schema:"namespace"`
+}
+
+type GetIncidentEventLogsRequest struct {
+	LogID string `schema:"log_id"`
+}
+
+type IncidentNotifyRequest struct {
+	IncidentID string `json:"incident_id" form:"required"`
+}

+ 98 - 16
dashboard/src/components/Table.tsx

@@ -9,8 +9,13 @@ import {
 } from "react-table";
 import Loading from "components/Loading";
 import Selector from "./Selector";
+import loading from "assets/loading.gif";
 
-const GlobalFilter: React.FunctionComponent<any> = ({ setGlobalFilter }) => {
+const GlobalFilter: React.FunctionComponent<any> = ({
+  setGlobalFilter,
+  onRefresh,
+  isRefreshing,
+}) => {
   const [value, setValue] = React.useState("");
   const onChange = (value: string) => {
     setValue(value);
@@ -18,16 +23,29 @@ const GlobalFilter: React.FunctionComponent<any> = ({ setGlobalFilter }) => {
   };
 
   return (
-    <SearchRow>
-      <i className="material-icons">search</i>
-      <SearchInput
-        value={value}
-        onChange={(e: any) => {
-          onChange(e.target.value);
-        }}
-        placeholder="Search"
-      />
-    </SearchRow>
+    <SearchRowWrapper>
+      <SearchRow>
+        <i className="material-icons">search</i>
+        <SearchInput
+          value={value}
+          onChange={(e: any) => {
+            onChange(e.target.value);
+          }}
+          placeholder="Search"
+        />
+      </SearchRow>
+      {typeof onRefresh === "function" && (
+        <RefreshButton onClick={onRefresh} disabled={isRefreshing}>
+          {isRefreshing ? (
+            <>
+              <img src={loading} alt="loading icon" />
+            </>
+          ) : (
+            <i className="material-icons">refresh</i>
+          )}
+        </RefreshButton>
+      )}
+    </SearchRowWrapper>
   );
 };
 
@@ -39,6 +57,10 @@ export type TableProps = {
   disableGlobalFilter?: boolean;
   disableHover?: boolean;
   enablePagination?: boolean;
+  hasError?: boolean;
+  errorMessage?: string;
+  onRefresh?: () => void;
+  isRefreshing?: boolean;
 };
 
 const MIN_PAGE_SIZE = 1;
@@ -51,6 +73,10 @@ const Table: React.FC<TableProps> = ({
   disableGlobalFilter = false,
   disableHover,
   enablePagination,
+  hasError,
+  errorMessage = "An unexpected error occurred, please try again.",
+  onRefresh,
+  isRefreshing = false,
 }) => {
   const {
     getTableProps,
@@ -87,10 +113,20 @@ const Table: React.FC<TableProps> = ({
   }, [data, enablePagination]);
 
   const renderRows = () => {
+    if (hasError) {
+      return (
+        <StyledTr disableHover={true} selected={false}>
+          <StyledTd colSpan={visibleColumns.length} align="center">
+            {errorMessage}
+          </StyledTd>
+        </StyledTr>
+      );
+    }
+
     if (isLoading) {
       return (
         <StyledTr disableHover={true} selected={false}>
-          <StyledTd colSpan={visibleColumns.length}>
+          <StyledTd colSpan={visibleColumns.length} height="150px">
             <Loading />
           </StyledTd>
         </StyledTr>
@@ -100,7 +136,9 @@ const Table: React.FC<TableProps> = ({
     if (!page.length) {
       return (
         <StyledTr disableHover={true} selected={false}>
-          <StyledTd colSpan={visibleColumns.length}>No data available</StyledTd>
+          <StyledTd colSpan={visibleColumns.length} align="center">
+            No data available
+          </StyledTd>
         </StyledTr>
       );
     }
@@ -140,7 +178,11 @@ const Table: React.FC<TableProps> = ({
   return (
     <TableWrapper>
       {!disableGlobalFilter && (
-        <GlobalFilter setGlobalFilter={setGlobalFilter} />
+        <GlobalFilter
+          setGlobalFilter={setGlobalFilter}
+          onRefresh={onRefresh}
+          isRefreshing={isRefreshing}
+        />
       )}
       <StyledTable {...getTableProps()}>
         <StyledTHead>
@@ -281,6 +323,12 @@ export const StyledTd = styled.td`
     padding-right: 10px;
   }
   user-select: text;
+
+  ${(props: { align?: "center" | "left" }) => {
+    if (props.align) {
+      return `text-align:${props.align};`;
+    }
+  }}
 `;
 
 export const StyledTHead = styled.thead`
@@ -332,8 +380,7 @@ const SearchRow = styled.div`
   min-width: 300px;
   max-width: min-content;
   background: #ffffff11;
-  margin-bottom: 15px;
-  margin-top: 0px;
+
   i {
     width: 18px;
     height: 18px;
@@ -342,3 +389,38 @@ const SearchRow = styled.div`
     font-size: 20px;
   }
 `;
+
+const SearchRowWrapper = styled.div`
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  margin-bottom: 15px;
+  margin-top: 0px;
+`;
+
+const RefreshButton = styled.button`
+  justify-self: flex-end;
+  border: 1px solid #ffffff00;
+  border-radius: 50%;
+  background: inherit;
+  color: #ffffff;
+  cursor: pointer;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  width: 35px;
+  height: 35px;
+
+  > i {
+    font-size: 20px;
+  }
+  > img {
+    width: 20px;
+    height: 20px;
+  }
+
+  :hover {
+    color: #ffffff88;
+    border-color: #ffffff88;
+  }
+`;

+ 17 - 5
dashboard/src/components/expanded-object/Header.tsx

@@ -10,24 +10,36 @@ type Props = {
   name: string;
   icon: string;
   inline_title_items?: React.ReactNodeArray;
+  sub_title_items?: React.ReactNodeArray;
+  materialIconClass?: string;
 };
 
 const Header: React.FunctionComponent<Props> = (props) => {
-  const { last_updated, back_link, icon, name, inline_title_items } = props;
+  const {
+    last_updated,
+    back_link,
+    icon,
+    name,
+    inline_title_items,
+    sub_title_items,
+    materialIconClass,
+  } = props;
 
   return (
     <HeaderWrapper>
       <BackButton to={back_link}>
         <BackButtonImg src={backArrow} />
       </BackButton>
-      <Title icon={icon} iconWidth="25px">
+      <Title icon={icon} iconWidth="25px" materialIconClass={materialIconClass}>
         {name}
         <Flex>{inline_title_items}</Flex>
       </Title>
 
-      <InfoWrapper>
-        <InfoText>Last updated {last_updated}</InfoText>
-      </InfoWrapper>
+      {sub_title_items || (
+        <InfoWrapper>
+          <InfoText>Last updated {last_updated}</InfoText>
+        </InfoWrapper>
+      )}
     </HeaderWrapper>
   );
 };

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

@@ -15,7 +15,7 @@ type Props = {
   sortType: "Newest" | "Oldest" | "Alphabetical";
 };
 
-const dateFormatter = (date: string) => {
+export const dateFormatter = (date: string | number) => {
   if (!date) {
     return "N/A";
   }

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

@@ -11,10 +11,10 @@ import { NamespaceList } from "./NamespaceList";
 import ClusterSettings from "./ClusterSettings";
 import useAuth from "shared/auth/useAuth";
 import Metrics from "./Metrics";
-import EventsTab from "./events/EventsTab";
 import EnvironmentList from "./preview-environments/EnvironmentList";
 import { useLocation } from "react-router";
 import { getQueryParam } from "shared/routing";
+import IncidentsTab from "./incidents/IncidentsTab";
 
 type TabEnum =
   | "preview_environments"
@@ -22,7 +22,7 @@ type TabEnum =
   | "settings"
   | "namespaces"
   | "metrics"
-  | "events";
+  | "incidents";
 
 const tabOptions: {
   label: string;
@@ -30,7 +30,7 @@ const tabOptions: {
 }[] = [
   { label: "Preview Environments", value: "preview_environments" },
   { label: "Nodes", value: "nodes" },
-  { label: "Events", value: "events" },
+  { label: "Incidents", value: "incidents" },
   { label: "Metrics", value: "metrics" },
   { label: "Namespaces", value: "namespaces" },
   { label: "Settings", value: "settings" },
@@ -53,8 +53,8 @@ export const Dashboard: React.FunctionComponent = () => {
           return <EnvironmentList />;
         }
         return <NodeList />;
-      case "events":
-        return <EventsTab />;
+      case "incidents":
+        return <IncidentsTab />;
       case "settings":
         return <ClusterSettings />;
       case "metrics":

+ 4 - 0
dashboard/src/main/home/cluster-dashboard/dashboard/Routes.tsx

@@ -2,6 +2,7 @@ import React, { useContext } from "react";
 import { Redirect, Route, Switch, useRouteMatch } from "react-router";
 import { Context } from "shared/Context";
 import { Dashboard } from "./Dashboard";
+import IncidentPage from "./incidents/IncidentPage";
 import ExpandedNodeView from "./node-view/ExpandedNodeView";
 import EnvironmentDetail from "./preview-environments/EnvironmentDetail";
 
@@ -11,6 +12,9 @@ export const Routes = () => {
   return (
     <>
       <Switch>
+        <Route path={`${url}/incidents/:incident_id`}>
+          <IncidentPage />
+        </Route>
         <Route path={`${url}/node-view/:nodeId`}>
           <ExpandedNodeView />
         </Route>

+ 233 - 0
dashboard/src/main/home/cluster-dashboard/dashboard/incidents/EventDrawer.tsx

@@ -0,0 +1,233 @@
+import Description from "components/Description";
+import useLastSeenPodStatus from "components/events/useLastSeenPodStatus";
+import Heading from "components/form-components/Heading";
+import Loading from "components/Loading";
+import { isEmpty } from "lodash";
+import React, { useContext, useEffect, useMemo, useState } from "react";
+import api from "shared/api";
+import { Context } from "shared/Context";
+import { capitalize } from "shared/string_utils";
+import styled from "styled-components";
+import ExpandedContainer from "./ExpandedContainer";
+import { IncidentContainerEvent, IncidentEvent } from "./IncidentPage";
+
+const EventDrawer: React.FC<{
+  event: IncidentEvent;
+  closeDrawer: () => void;
+}> = ({ event, closeDrawer }) => {
+  const { currentProject, currentCluster } = useContext(Context);
+
+  const [containerLogs, setContainerLogs] = useState<{ [key: string]: string }>(
+    null
+  );
+
+  const {
+    status,
+    hasError: hasPodStatusErrored,
+    isLoading: isPodStatusLoading,
+  } = useLastSeenPodStatus({
+    podName: event?.pod_name,
+    namespace: event?.namespace,
+    resource_type: "pod",
+  });
+
+  const containers: IncidentContainerEvent[] = useMemo(() => {
+    if (isEmpty(event?.container_events)) {
+      return [];
+    }
+
+    return Object.values(event?.container_events || {});
+  }, [event]);
+
+  useEffect(() => {
+    if (!event) {
+      return () => {};
+    }
+
+    let isSubscribed = true;
+
+    const containersWithLogs = containers.filter(
+      (container) => container.log_id
+    );
+
+    const promises = containersWithLogs.map((container) => {
+      return api
+        .getIncidentLogsByLogId<{ contents: string }>(
+          "<token>",
+          {
+            log_id: container.log_id,
+          },
+          {
+            project_id: currentProject.id,
+            cluster_id: currentCluster.id,
+          }
+        )
+        .then((res) => ({
+          contents: res.data?.contents,
+          container_name: container.container_name,
+        }));
+    });
+
+    Promise.all(promises)
+      .then((data) => {
+        if (!isSubscribed) {
+          return;
+        }
+
+        const tmpContainerLogs = data.reduce<{ [key: string]: string }>(
+          (acc, c) => {
+            acc[c.container_name] = c.contents;
+            return acc;
+          },
+          {}
+        );
+
+        setContainerLogs(tmpContainerLogs);
+      })
+      .catch(() => console.log("nope"));
+
+    return () => {
+      isSubscribed = false;
+    };
+  }, [containers]);
+
+  if (!event) {
+    return null;
+  }
+
+  if (!containerLogs) {
+    return <Loading />;
+  }
+
+  return (
+    <EventDrawerContainer>
+      <EventDrawerTitleContainer>
+        <EventDrawerTitle>Pod: {event?.pod_name}</EventDrawerTitle>
+        <BackButton onClick={closeDrawer}>
+          <i className="material-icons">close</i>
+        </BackButton>
+      </EventDrawerTitleContainer>
+
+      <StyledHelper>
+        {hasPodStatusErrored ? (
+          "We couldn't retrieve last pod status, please try again later"
+        ) : (
+          <>
+            {isPodStatusLoading ? (
+              <Loading />
+            ) : (
+              <>
+                Latest pod status: {capitalize(status)}{" "}
+                <StatusColor status={status?.toLowerCase()}></StatusColor>
+              </>
+            )}
+          </>
+        )}
+      </StyledHelper>
+      <MetadataContainer>
+        <Heading>Overview</Heading>
+        <Description>
+          Event reported on{" "}
+          {Intl.DateTimeFormat([], {
+            // @ts-ignore
+            dateStyle: "full",
+            timeStyle: "long",
+          }).format(new Date(event?.timestamp))}
+        </Description>
+        <Description>{event?.message}</Description>
+        <Br />
+      </MetadataContainer>
+      {containers.map((container) => (
+        <ExpandedContainer
+          container={container}
+          logs={containerLogs[container.container_name]}
+        />
+      ))}
+    </EventDrawerContainer>
+  );
+};
+
+export default EventDrawer;
+
+const EventDrawerContainer = styled.div`
+  position: relative;
+  color: #ffffff;
+  padding: 25px 30px;
+`;
+
+const EventDrawerTitle = styled.span`
+  display: block;
+  font-size: 24px;
+  font-weight: bold;
+  color: #ffffff;
+`;
+
+const Br = styled.div`
+  width: 100%;
+  height: 20px;
+`;
+
+const MetadataContainer = styled.div`
+  border-radius: 6px;
+  background: #2e3135;
+  padding: 0 20px;
+  overflow-y: auto;
+  min-height: 100px;
+  font-size: 13px;
+  margin: 12px 0;
+`;
+
+const StyledHelper = styled.div`
+  color: #aaaabb;
+  line-height: 1.6em;
+  font-size: 13px;
+  margin-top: 6px;
+`;
+
+const BackButton = styled.div`
+  display: flex;
+  width: 37px;
+  z-index: 1;
+  cursor: pointer;
+  height: 37px;
+  align-items: center;
+  justify-content: center;
+  border: 1px solid #ffffff55;
+  border-radius: 100px;
+  background: #ffffff11;
+  color: #ffffffaa;
+
+  > i {
+    font-size: 20px;
+  }
+
+  :hover {
+    background: #ffffff22;
+    > img {
+      opacity: 1;
+    }
+  }
+`;
+
+const StatusColor = styled.div`
+  display: inline-block;
+  margin-right: 7px;
+  width: 7px;
+  min-width: 7px;
+  height: 7px;
+  background: ${(props: { status: string }) =>
+    props.status === "running"
+      ? "#4797ff"
+      : props.status === "failed" || props.status === "deleted"
+      ? "#ed5f85"
+      : props.status === "completed"
+      ? "#00d12a"
+      : "#f5cb42"};
+  border-radius: 20px;
+`;
+
+const EventDrawerTitleContainer = styled.div`
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+`;

+ 62 - 0
dashboard/src/main/home/cluster-dashboard/dashboard/incidents/ExpandedContainer.tsx

@@ -0,0 +1,62 @@
+import Description from "components/Description";
+import Heading from "components/form-components/Heading";
+import React from "react";
+import styled from "styled-components";
+import { IncidentContainerEvent } from "./IncidentPage";
+
+type Props = {
+  container: IncidentContainerEvent;
+  logs: string;
+};
+
+const ExpandedContainer: React.FC<Props> = ({ container, logs }) => {
+  return (
+    <StyledCard>
+      <MetadataContainer>
+        <Heading>Container: {container.container_name}</Heading>
+        <Description>
+          Container exited with code {container.exit_code}, {container.message}
+        </Description>
+        <Description>
+          The following are the container logs from this application instance:
+        </Description>
+        <LogContainer>
+          {logs ? <>{logs}</> : <>No logs available for this container.</>}
+        </LogContainer>
+      </MetadataContainer>
+    </StyledCard>
+  );
+};
+
+export default ExpandedContainer;
+
+const StyledCard = styled.div`
+  display: grid;
+  grid-row-gap: 15px;
+  grid-template-columns: 1;
+`;
+
+const MetadataContainer = styled.div`
+  margin-bottom: 3px;
+  border-radius: 6px;
+  background: #2e3135;
+  padding: 0 20px;
+  overflow-y: auto;
+  min-height: 100px;
+  font-size: 13px;
+  margin: 12px 0;
+`;
+
+const LogContainer = styled.div`
+  padding: 14px;
+  font-size: 13px;
+  background: #121318;
+  user-select: text;
+  overflow-wrap: break-word;
+  overflow-y: auto;
+  min-height: 55px;
+  color: #aaaabb;
+  height: 400px;
+  border-radius: 4px;
+  margin: 12px 0 24px 0;
+`;

+ 524 - 0
dashboard/src/main/home/cluster-dashboard/dashboard/incidents/IncidentPage.tsx

@@ -0,0 +1,524 @@
+import Loading from "components/Loading";
+import React, { useContext, useEffect, useMemo, useState } from "react";
+import { useParams } from "react-router";
+import styled from "styled-components";
+
+import loading from "assets/loading.gif";
+import { Drawer, withStyles } from "@material-ui/core";
+import EventDrawer from "./EventDrawer";
+import { useRouting } from "shared/routing";
+import api from "shared/api";
+import { Context } from "shared/Context";
+import DynamicLink from "components/DynamicLink";
+import Header from "components/expanded-object/Header";
+import { capitalize } from "shared/string_utils";
+import Description from "components/Description";
+import { dateFormatter } from "../../chart/JobRunTable";
+
+type IncidentPageParams = {
+  incident_id: string;
+};
+
+const IncidentPage = () => {
+  const { incident_id } = useParams<IncidentPageParams>();
+
+  const { currentProject, currentCluster } = useContext(Context);
+
+  const [incident, setIncident] = useState<Incident>(null);
+
+  const [isRefreshing, setIsRefreshing] = useState(false);
+  const [selectedEvent, setSelectedEvent] = useState<IncidentEvent>(null);
+
+  const { getQueryParam, pushFiltered } = useRouting();
+
+  useEffect(() => {
+    let isSubscribed = true;
+
+    setIncident(null);
+
+    api
+      .getIncidentById<Incident>(
+        "<token>",
+        { incident_id },
+        {
+          cluster_id: currentCluster.id,
+          project_id: currentProject.id,
+        }
+      )
+      .then((res) => {
+        if (!isSubscribed) {
+          return;
+        }
+
+        let incident = res.data;
+
+        incident.events = convertEventsTimestampsToMilliseconds(
+          incident.events
+        );
+
+        setIncident(incident);
+      });
+
+    return () => {
+      isSubscribed = false;
+    };
+  }, [incident_id]);
+
+  const refreshIncident = async () => {
+    setIsRefreshing(true);
+    try {
+      let incident = await api
+        .getIncidentById<Incident>(
+          "<token>",
+          { incident_id },
+          {
+            cluster_id: currentCluster.id,
+            project_id: currentProject.id,
+          }
+        )
+        .then((res) => res.data);
+
+      incident.events = convertEventsTimestampsToMilliseconds(incident.events);
+
+      setIncident(incident);
+    } catch (error) {
+    } finally {
+      setIsRefreshing(false);
+    }
+  };
+
+  const events = useMemo(() => {
+    return groupEventsByDate(incident?.events);
+  }, [incident]);
+
+  if (incident === null) {
+    return <Loading />;
+  }
+
+  const getBackLink = () => {
+    return (
+      getQueryParam("redirect_url") ||
+      "/cluster-dashboard?selected_tab=incidents"
+    );
+  };
+
+  const getResourceLink = () => {
+    let chartName = incident?.chart_name.split("-")[0] || "web";
+    let namespace = incident?.incident_id.split(":")[2] || "default";
+
+    if (chartName == "job") {
+      return `/jobs/${currentCluster.name}/${namespace}/${incident?.release_name}`;
+    }
+
+    return `/applications/${currentCluster.name}/${namespace}/${incident?.release_name}`;
+  };
+
+  return (
+    <StyledExpandedNodeView>
+      <HeaderWrapper>
+        <Header
+          last_updated={dateFormatter(incident.updated_at * 1000)}
+          back_link={getBackLink()}
+          name={"Incident for " + incident.release_name}
+          icon={"error"}
+          materialIconClass="material-icons"
+          inline_title_items={[
+            <ResourceLink
+              key="resource_link"
+              to={getResourceLink()}
+              target="_blank"
+              onClick={(e) => e.stopPropagation()}
+            >
+              {incident.release_name}
+              <i className="material-icons">open_in_new</i>
+            </ResourceLink>,
+          ]}
+          sub_title_items={[
+            <StatusContainer>
+              <Status>
+                <StatusDot status={incident.latest_state} />
+                {capitalize(incident.latest_state)}
+              </Status>
+              <StatusText>
+                - started {dateFormatter(incident.created_at * 1000)}, last
+                updated {dateFormatter(incident.updated_at * 1000)}
+              </StatusText>
+              <Description></Description>
+            </StatusContainer>,
+          ]}
+        />
+      </HeaderWrapper>
+      <LineBreak />
+      <BodyWrapper>
+        <RefreshButton onClick={refreshIncident} disabled={isRefreshing}>
+          {isRefreshing ? (
+            <>
+              <img src={loading} alt="loading icon" />
+            </>
+          ) : (
+            <i className="material-icons">refresh</i>
+          )}
+        </RefreshButton>
+        {Object.entries(events).map(([date, events_list]) => (
+          <React.Fragment key={date}>
+            <StyledDate>{date}</StyledDate>
+
+            {events_list.map((event) => {
+              return (
+                <StyledCard
+                  key={event.event_id}
+                  onClick={() => setSelectedEvent(event)}
+                  active={selectedEvent?.event_id === event.event_id}
+                >
+                  <ContentContainer>
+                    <Icon status={"normal"} className="material-icons-outlined">
+                      info
+                    </Icon>
+                    <EventInformation>
+                      <EventName>
+                        <Helper>Pod:</Helper>
+                        {event.pod_name}
+                      </EventName>
+                      <EventReason>{event.message}</EventReason>
+                    </EventInformation>
+                  </ContentContainer>
+                  <ActionContainer>
+                    <TimestampContainer>
+                      <TimestampIcon className="material-icons-outlined">
+                        access_time
+                      </TimestampIcon>
+                      <span>
+                        {Intl.DateTimeFormat([], {
+                          // @ts-ignore
+                          dateStyle: "full",
+                          timeStyle: "long",
+                        }).format(new Date(event.timestamp))}
+                      </span>
+                    </TimestampContainer>
+                  </ActionContainer>
+                </StyledCard>
+              );
+            })}
+          </React.Fragment>
+        ))}
+      </BodyWrapper>
+      <StyledDrawer
+        anchor="right"
+        open={!!selectedEvent}
+        onClose={() => setSelectedEvent(null)}
+      >
+        <EventDrawer
+          event={selectedEvent}
+          closeDrawer={() => setSelectedEvent(null)}
+        />
+      </StyledDrawer>
+    </StyledExpandedNodeView>
+  );
+};
+
+export default IncidentPage;
+
+const convertEventsTimestampsToMilliseconds = (events: IncidentEvent[]) => {
+  return events.map((e) => {
+    let newEvent = e;
+
+    newEvent.timestamp = newEvent.timestamp * 1000;
+
+    return newEvent;
+  });
+};
+
+const groupEventsByDate = (
+  events: IncidentEvent[]
+): { [key: string]: IncidentEvent[] } => {
+  if (!events?.length) {
+    return {};
+  }
+
+  return events.reduce<{ [key: string]: IncidentEvent[] }>(
+    (accumulator, current) => {
+      // @ts-ignore
+      const date = Intl.DateTimeFormat([], { dateStyle: "full" }).format(
+        new Date(current.timestamp)
+      );
+
+      if (accumulator[date]?.length) {
+        accumulator[date].push(current);
+      } else {
+        accumulator[date] = [current];
+      }
+
+      return accumulator;
+    },
+    {}
+  );
+};
+
+export type IncidentContainerEvent = {
+  container_name: string;
+  reason: string;
+  message: string;
+  exit_code: number;
+  log_id: string;
+};
+
+export type IncidentEvent = {
+  event_id: string;
+  pod_name: string;
+  cluster: string;
+  namespace: string;
+  release_name: string;
+  release_type: string;
+  timestamp: number;
+  pod_phase: string;
+  pod_status: string;
+  reason: string;
+  message: string;
+  container_events: {
+    [key: string]: IncidentContainerEvent;
+  };
+};
+
+export type Incident = {
+  incident_id: string;
+  release_name: string; // eg: "sample-web"
+  latest_state: string; // "ONGOING" or "RESOLVED"
+  latest_reason: string; // eg: "Out of memory",
+  latest_message: string; // eg: "Application crash due to out of memory issue"
+  events: IncidentEvent[];
+  created_at: number;
+  updated_at: number;
+  chart_name: string;
+};
+
+const RefreshButton = styled.button`
+  position: absolute;
+  right: 0px;
+  top: 20px;
+  border: 1px solid #ffffff00;
+  border-radius: 50%;
+  background: inherit;
+  color: #ffffff;
+  cursor: pointer;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  width: 35px;
+  height: 35px;
+
+  > i {
+    font-size: 20px;
+  }
+  > img {
+    width: 20px;
+    height: 20px;
+  }
+
+  :hover {
+    color: #ffffff88;
+    border-color: #ffffff88;
+  }
+`;
+
+const LineBreak = styled.div`
+  width: calc(100% - 0px);
+  height: 2px;
+  background: #ffffff20;
+  margin: 10px 0px 35px;
+`;
+
+const BodyWrapper = styled.div`
+  position: relative;
+  width: 100%;
+  height: 100%;
+  overflow: hidden;
+`;
+
+const HeaderWrapper = styled.div`
+  position: relative;
+`;
+
+const StyledExpandedNodeView = styled.div`
+  width: 100%;
+  z-index: 0;
+  animation: fadeIn 0.3s;
+  animation-timing-function: ease-out;
+  animation-fill-mode: forwards;
+  display: flex;
+  overflow-y: auto;
+  padding-bottom: 120px;
+  flex-direction: column;
+  overflow: visible;
+
+  @keyframes fadeIn {
+    from {
+      opacity: 0;
+    }
+    to {
+      opacity: 1;
+    }
+  }
+`;
+
+const StyledDate = styled.div`
+  font-size: 18px;
+  font-weight: bold;
+  color: #ffffff;
+  margin-bottom: 20px;
+  margin-top: 20px;
+  :first-child {
+    margin-top: 0px;
+  }
+`;
+
+const StyledCard = styled.div<{ active: boolean }>`
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  border: 1px solid ${({ active }) => (active ? "#819bfd" : "#ffffff44")};
+  background: #ffffff08;
+  margin-bottom: 5px;
+  border-radius: 10px;
+  padding: 14px;
+  overflow: hidden;
+  height: 80px;
+  font-size: 13px;
+  cursor: pointer;
+  :hover {
+    background: #ffffff11;
+    border: 1px solid ${({ active }) => (active ? "#819bfd" : "#ffffff66")};
+  }
+  animation: fadeIn 0.5s;
+  @keyframes fadeIn {
+    from {
+      opacity: 0;
+    }
+    to {
+      opacity: 1;
+    }
+  }
+  :not(:last-child) {
+    margin-bottom: 15px;
+  }
+`;
+
+const ContentContainer = styled.div`
+  display: flex;
+  height: 100%;
+  width: 100%;
+  align-items: center;
+`;
+
+const Icon = styled.span<{ status: "critical" | "normal" }>`
+  font-size: 20px;
+  margin-left: 10px;
+  margin-right: 20px;
+  color: ${({ status }) => (status === "critical" ? "#ff385d" : "#aaaabb")};
+`;
+
+const EventInformation = styled.div`
+  display: flex;
+  flex-direction: column;
+  justify-content: space-around;
+  height: 100%;
+`;
+
+const EventName = styled.div`
+  font-family: "Work Sans", sans-serif;
+  font-weight: 500;
+  color: #ffffff;
+`;
+
+const Helper = styled.span`
+  text-transform: capitalize;
+  color: #ffffff44;
+  margin-right: 5px;
+`;
+
+const EventReason = styled.div`
+  font-family: "Work Sans", sans-serif;
+  color: #aaaabb;
+  margin-top: 5px;
+`;
+
+const ActionContainer = styled.div`
+  display: flex;
+  align-items: center;
+  white-space: nowrap;
+  height: 100%;
+`;
+
+const TimestampContainer = styled.div`
+  display: flex;
+  white-space: nowrap;
+  align-items: center;
+  justify-self: flex-end;
+  color: #ffffff55;
+  margin-right: 10px;
+  font-size: 13px;
+  min-width: 130px;
+  justify-content: space-between;
+`;
+
+const TimestampIcon = styled.span`
+  margin-right: 7px;
+  font-size: 18px;
+`;
+
+const StyledDrawer = withStyles({
+  paperAnchorRight: {
+    background: "#202227",
+    minWidth: "700px",
+  },
+})(Drawer);
+
+const ResourceLink = styled(DynamicLink)`
+  font-size: 13px;
+  font-weight: 400;
+  margin-left: 20px;
+  color: #aaaabb;
+  display: flex;
+  align-items: center;
+
+  :hover {
+    text-decoration: underline;
+    color: white;
+  }
+
+  > i {
+    margin-left: 7px;
+    font-size: 17px;
+  }
+`;
+
+const Status = styled.span`
+  font-size: 13px;
+  display: flex;
+  align-items: center;
+  margin-left: 1px;
+  min-height: 17px;
+  color: #a7a6bb;
+  margin-right: 6px;
+`;
+
+const StatusDot = styled.div`
+  width: 8px;
+  height: 8px;
+  background: ${(props: { status: string }) =>
+    props.status === "ONGOING" ? "#ed5f85" : "#4797ff"};
+  border-radius: 20px;
+  margin-left: 3px;
+  margin-right: 15px;
+`;
+
+const StatusContainer = styled.div`
+  display: flex;
+  align-items: center;
+  font-size: 13px;
+  color: #aaaabb;
+  width: 100%;
+`;
+
+const StatusText = styled.div`
+  width: 100%;
+`;

+ 215 - 0
dashboard/src/main/home/cluster-dashboard/dashboard/incidents/IncidentsTab.tsx

@@ -0,0 +1,215 @@
+import Loading from "components/Loading";
+import React, { useContext, useEffect, useState } from "react";
+import api from "shared/api";
+import { Context } from "shared/Context";
+import styled from "styled-components";
+import IncidentsTable from "./IncidentsTable";
+
+export type DetectAgentResponse = {
+  version: string;
+};
+
+const IncidentsTab = () => {
+  const { currentProject, currentCluster } = useContext(Context);
+  const [isAgentInstalled, setIsAgentInstalled] = useState(false);
+  const [isAgentOutdated, setIsAgentOutdated] = useState(false);
+  const [isLoading, setIsLoading] = useState(true);
+
+  useEffect(() => {
+    api
+      .detectPorterAgent<DetectAgentResponse>(
+        "<token>",
+        {},
+        {
+          project_id: currentProject.id,
+          cluster_id: currentCluster.id,
+        }
+      )
+      .then((res) => res.data)
+      .then((data) => {
+        if (data.version === "v1") {
+          setIsAgentInstalled(true);
+          setIsAgentOutdated(true);
+        } else {
+          setIsAgentInstalled(true);
+          setIsAgentOutdated(false);
+        }
+      })
+      .catch(() => {
+        setIsAgentInstalled(false);
+      })
+      .finally(() => {
+        setIsLoading(false);
+      });
+  }, []);
+
+  const upgradeAgent = async () => {
+    const project_id = currentProject?.id;
+    const cluster_id = currentCluster?.id;
+    try {
+      await api.upgradePorterAgent(
+        "<token>",
+        {},
+        {
+          project_id,
+          cluster_id,
+        }
+      );
+      setIsAgentOutdated(false);
+    } catch (err) {
+      setIsAgentOutdated(true);
+    }
+  };
+
+  const installAgent = async () => {
+    const project_id = currentProject?.id;
+    const cluster_id = currentCluster?.id;
+
+    api
+      .installPorterAgent("<token>", {}, { project_id, cluster_id })
+      .then(() => {
+        setIsAgentInstalled(true);
+      })
+      .catch(() => {
+        setIsAgentInstalled(false);
+      });
+  };
+
+  const triggerInstall = () => {
+    if (isAgentOutdated) {
+      upgradeAgent();
+      return;
+    }
+
+    installAgent();
+  };
+
+  if (isLoading) {
+    return (
+      <StyledCard>
+        <Loading height="200px" />
+      </StyledCard>
+    );
+  }
+
+  if (!isAgentInstalled || isAgentOutdated) {
+    return (
+      <Placeholder>
+        <AgentButtonContainer>
+          <Header>Incident detection is not enabled on this cluster.</Header>
+          <Subheader>
+            In order to view incidents, you must enable incident detection on
+            this cluster.
+          </Subheader>
+          <InstallPorterAgentButton onClick={() => triggerInstall()}>
+            <i className="material-icons">add</i> Enable Incident Detection
+          </InstallPorterAgentButton>
+        </AgentButtonContainer>
+      </Placeholder>
+    );
+  }
+
+  return (
+    <StyledCard>
+      <IncidentsTable />
+    </StyledCard>
+  );
+};
+
+export default IncidentsTab;
+
+const StyledCard = styled.div`
+  margin-top: 35px;
+  background: #26282f;
+  padding: 14px;
+  border-radius: 8px;
+  box-shadow: 0 4px 15px 0px #00000055;
+  position: relative;
+  border: 2px solid #9eb4ff00;
+  width: 100%;
+  :not(:last-child) {
+    margin-bottom: 25px;
+  }
+`;
+
+const InstallPorterAgentButton = styled.button`
+  display: flex;
+  flex-direction: row;
+  align-items: center;
+  justify-content: space-between;
+  font-size: 13px;
+  width: 200px;
+  cursor: pointer;
+  font-family: "Work Sans", sans-serif;
+  border: none;
+  border-radius: 5px;
+  color: white;
+  height: 35px;
+  padding: 0px 8px;
+  padding-bottom: 1px;
+  margin-top: 20px;
+  font-weight: 500;
+  padding-right: 15px;
+  overflow: hidden;
+  white-space: nowrap;
+  text-overflow: ellipsis;
+  box-shadow: 0 5px 8px 0px #00000010;
+  cursor: ${(props: { disabled?: boolean }) =>
+    props.disabled ? "not-allowed" : "pointer"};
+  background: ${(props: { disabled?: boolean }) =>
+    props.disabled ? "#aaaabbee" : "#5561C0"};
+  :hover {
+    filter: ${(props) => (!props.disabled ? "brightness(120%)" : "")};
+  }
+  > i {
+    color: white;
+    width: 18px;
+    height: 18px;
+    font-weight: 600;
+    font-size: 12px;
+    border-radius: 20px;
+    display: flex;
+    align-items: center;
+    margin-right: 5px;
+    justify-content: center;
+  }
+`;
+
+const Placeholder = styled.div`
+  padding: 30px;
+  margin-top: 35px;
+  padding-bottom: 40px;
+  font-size: 13px;
+  color: #ffffff44;
+  min-height: 400px;
+  height: 50vh;
+  background: #ffffff11;
+  border-radius: 8px;
+  display: flex;
+  align-items: left;
+  justify-content: center;
+  flex-direction: column;
+
+  > i {
+    font-size: 18px;
+    margin-right: 8px;
+  }
+`;
+
+const AgentButtonContainer = styled.div`
+  display: flex;
+  align-items: left;
+  justify-content: center;
+  flex-direction: column;
+  width: 500px;
+  margin: 0 auto;
+`;
+
+const Header = styled.div`
+  font-weight: 500;
+  color: #aaaabb;
+  font-size: 16px;
+  margin-bottom: 15px;
+`;
+
+const Subheader = styled.div``;

+ 209 - 0
dashboard/src/main/home/cluster-dashboard/dashboard/incidents/IncidentsTable.tsx

@@ -0,0 +1,209 @@
+import Table from "components/Table";
+import React, { useContext, useEffect, useMemo, useState } from "react";
+import { Column } from "react-table";
+import api from "shared/api";
+import { Context } from "shared/Context";
+import { hardcodedIcons } from "shared/hardcodedNameDict";
+import { useRouting } from "shared/routing";
+import { capitalize } from "shared/string_utils";
+import styled from "styled-components";
+import { dateFormatter } from "../../chart/JobRunTable";
+import { Incident } from "./IncidentPage";
+
+export type IncidentsWithoutEvents = Omit<
+  Incident,
+  "events" | "incident_id"
+> & {
+  id: string;
+};
+
+const IncidentsTable = () => {
+  const { currentCluster, currentProject, setCurrentError } = useContext(
+    Context
+  );
+  const { pushFiltered } = useRouting();
+
+  const [incidents, setIncidents] = useState<IncidentsWithoutEvents[]>(null);
+  const [hasError, setHasError] = useState(false);
+
+  const [isRefreshing, setIsRefreshing] = useState(false);
+
+  useEffect(() => {
+    let isSubscribed = true;
+    setIncidents(null);
+    setHasError(false);
+
+    api
+      .getIncidents<{ incidents: IncidentsWithoutEvents[] }>(
+        "<token>",
+        {},
+        {
+          project_id: currentProject.id,
+          cluster_id: currentCluster.id,
+        }
+      )
+      .then((res) => {
+        if (!isSubscribed) {
+          return;
+        }
+
+        setIncidents(res.data?.incidents || []);
+      })
+      .catch((err) => {
+        setHasError(true);
+        setCurrentError(err);
+      });
+
+    return () => {
+      isSubscribed = false;
+    };
+  }, [currentCluster, currentProject]);
+
+  const refreshIncidents = async () => {
+    setIsRefreshing(true);
+    try {
+      const incidents = await api
+        .getIncidents<{ incidents: IncidentsWithoutEvents[] }>(
+          "<token>",
+          {},
+          {
+            project_id: currentProject.id,
+            cluster_id: currentCluster.id,
+          }
+        )
+        .then((res) => res.data?.incidents || []);
+
+      setIncidents(incidents);
+    } catch (err) {
+      setHasError(true);
+      setCurrentError(err);
+    } finally {
+      setIsRefreshing(false);
+    }
+  };
+
+  const columns = useMemo(() => {
+    return [
+      {
+        Header: "Release",
+        accessor: "release_name",
+        Cell: ({ row }) => {
+          let original = row.original;
+
+          let chartName = original?.chart_name.split("-")[0] || "web";
+
+          return (
+            <KindContainer>
+              <Icon src={hardcodedIcons[chartName] || hardcodedIcons["web"]} />
+              <Kind>{original.release_name}</Kind>
+            </KindContainer>
+          );
+        },
+      },
+      {
+        Header: "Status",
+        accessor: "latest_state",
+        Cell: ({ row }) => {
+          let original = row.original;
+
+          return (
+            <Status>
+              <StatusDot status={original.latest_state} />
+              {capitalize(original.latest_state)}
+            </Status>
+          );
+        },
+      },
+      {
+        Header: "Message",
+        accessor: "latest_message",
+        Cell: ({ row }) => {
+          let original = row.original;
+
+          return <Message>{original.latest_message}</Message>;
+        },
+      },
+      {
+        Header: "Started",
+        accessor: "created_at",
+        Cell: ({ row }) => {
+          let original = row.original;
+
+          return dateFormatter(original.created_at * 1000);
+        },
+      },
+      {
+        Header: "Last Updated",
+        accessor: "updated_at",
+        Cell: ({ row }) => {
+          let original = row.original;
+
+          return dateFormatter(original.updated_at * 1000);
+        },
+      },
+    ] as Column<IncidentsWithoutEvents>[];
+  }, []);
+
+  const data = useMemo(() => {
+    if (!incidents) {
+      return [];
+    }
+    return incidents;
+  }, [incidents]);
+
+  return (
+    <Table
+      columns={columns}
+      data={data}
+      isLoading={incidents === null}
+      onRowClick={(row: any) => {
+        pushFiltered(`/cluster-dashboard/incidents/${row?.original?.id}`, []);
+      }}
+      hasError={hasError}
+      onRefresh={refreshIncidents}
+      isRefreshing={isRefreshing}
+    />
+  );
+};
+
+export default IncidentsTable;
+
+const KindContainer = styled.div`
+  display: flex;
+  align-items: center;
+  min-width: 200px;
+`;
+
+const Kind = styled.div`
+  margin-left: 8px;
+`;
+
+const Icon = styled.img`
+  height: 20px;
+`;
+
+const Status = styled.span`
+  font-size: 13px;
+  display: flex;
+  align-items: center;
+  margin-left: 1px;
+  min-height: 17px;
+  color: #a7a6bb;
+`;
+
+const StatusDot = styled.div`
+  width: 8px;
+  height: 8px;
+  background: ${(props: { status: string }) =>
+    props.status === "ONGOING" ? "#ed5f85" : "#4797ff"};
+  border-radius: 20px;
+  margin-left: 3px;
+  margin-right: 15px;
+`;
+
+const Message = styled.div`
+  white-space: nowrap;
+  overflow-x: hidden;
+  text-overflow: ellipsis;
+  max-width: 500px;
+`;

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

@@ -28,9 +28,8 @@ import { useWebsockets } from "shared/hooks/useWebsockets";
 import useAuth from "shared/auth/useAuth";
 import TitleSection from "components/TitleSection";
 import DeploymentType from "./DeploymentType";
-import EventsTab from "./events/EventsTab";
-import { PopulatedEnvGroup } from "components/porter-form/types";
 import { onlyInLeft } from "shared/array_utils";
+import IncidentsTab from "./incidents/IncidentsTab";
 
 type Props = {
   namespace: string;
@@ -426,8 +425,13 @@ const ExpandedChart: React.FC<Props> = (props) => {
     switch (currentTab) {
       case "metrics":
         return <MetricsSection currentChart={chart} />;
-      case "events":
-        return <EventsTab controllers={controllers} />;
+      case "incidents":
+        return (
+          <IncidentsTab
+            releaseName={chart?.name}
+            namespace={chart?.namespace}
+          />
+        );
       case "status":
         if (isLoadingChartData) {
           return (
@@ -520,7 +524,7 @@ const ExpandedChart: React.FC<Props> = (props) => {
     let rightTabOptions = [] as any[];
     let leftTabOptions = [] as any[];
     leftTabOptions.push({ label: "Status", value: "status" });
-    leftTabOptions.push({ label: "Events", value: "events" });
+    leftTabOptions.push({ label: "Incidents", value: "incidents" });
 
     if (props.isMetricsInstalled) {
       leftTabOptions.push({ label: "Metrics", value: "metrics" });

+ 217 - 0
dashboard/src/main/home/cluster-dashboard/expanded-chart/incidents/IncidentsTab.tsx

@@ -0,0 +1,217 @@
+import Loading from "components/Loading";
+import React, { useContext, useEffect, useState } from "react";
+import api from "shared/api";
+import { Context } from "shared/Context";
+import styled from "styled-components";
+import IncidentsTable from "./IncidentsTable";
+
+export type DetectAgentResponse = {
+  version: string;
+};
+
+const IncidentsTab = (props: {
+  releaseName: string;
+  namespace: string;
+}): JSX.Element => {
+  const { currentProject, currentCluster } = useContext(Context);
+  const [isAgentInstalled, setIsAgentInstalled] = useState(false);
+  const [isAgentOutdated, setIsAgentOutdated] = useState(false);
+  const [isLoading, setIsLoading] = useState(true);
+
+  useEffect(() => {
+    api
+      .detectPorterAgent<DetectAgentResponse>(
+        "<token>",
+        {},
+        {
+          project_id: currentProject.id,
+          cluster_id: currentCluster.id,
+        }
+      )
+      .then((res) => res.data)
+      .then((data) => {
+        if (data.version === "v1") {
+          setIsAgentInstalled(true);
+          setIsAgentOutdated(true);
+        } else {
+          setIsAgentInstalled(true);
+          setIsAgentOutdated(false);
+        }
+      })
+      .catch(() => {
+        setIsAgentInstalled(false);
+      })
+      .finally(() => {
+        setIsLoading(false);
+      });
+  }, []);
+
+  const upgradeAgent = async () => {
+    const project_id = currentProject?.id;
+    const cluster_id = currentCluster?.id;
+    try {
+      await api.upgradePorterAgent(
+        "<token>",
+        {},
+        {
+          project_id,
+          cluster_id,
+        }
+      );
+      setIsAgentOutdated(false);
+    } catch (err) {
+      setIsAgentOutdated(true);
+    }
+  };
+
+  const installAgent = async () => {
+    const project_id = currentProject?.id;
+    const cluster_id = currentCluster?.id;
+
+    api
+      .installPorterAgent("<token>", {}, { project_id, cluster_id })
+      .then(() => {
+        setIsAgentInstalled(true);
+      })
+      .catch(() => {
+        setIsAgentInstalled(false);
+      });
+  };
+
+  const triggerInstall = () => {
+    if (isAgentOutdated) {
+      upgradeAgent();
+      return;
+    }
+
+    installAgent();
+  };
+
+  if (isLoading) {
+    return (
+      <StyledCard>
+        <Loading height="200px" />
+      </StyledCard>
+    );
+  }
+
+  if (!isAgentInstalled || isAgentOutdated) {
+    return (
+      <Placeholder>
+        <AgentButtonContainer>
+          <Header>Incident detection is not enabled on this cluster.</Header>
+          <Subheader>
+            In order to view incidents, you must enable incident detection on
+            this cluster.
+          </Subheader>
+          <InstallPorterAgentButton onClick={() => triggerInstall()}>
+            <i className="material-icons">add</i> Enable Incident Detection
+          </InstallPorterAgentButton>
+        </AgentButtonContainer>
+      </Placeholder>
+    );
+  }
+
+  return (
+    <StyledCard>
+      <IncidentsTable {...props} />
+    </StyledCard>
+  );
+};
+
+export default IncidentsTab;
+
+const StyledCard = styled.div`
+  background: #26282f;
+  padding: 14px;
+  border-radius: 8px;
+  box-shadow: 0 4px 15px 0px #00000055;
+  position: relative;
+  border: 2px solid #9eb4ff00;
+  width: 100%;
+  :not(:last-child) {
+    margin-bottom: 25px;
+  }
+`;
+
+const InstallPorterAgentButton = styled.button`
+  display: flex;
+  flex-direction: row;
+  align-items: center;
+  justify-content: space-between;
+  font-size: 13px;
+  width: 200px;
+  cursor: pointer;
+  font-family: "Work Sans", sans-serif;
+  border: none;
+  border-radius: 5px;
+  color: white;
+  height: 35px;
+  padding: 0px 8px;
+  padding-bottom: 1px;
+  margin-top: 20px;
+  font-weight: 500;
+  padding-right: 15px;
+  overflow: hidden;
+  white-space: nowrap;
+  text-overflow: ellipsis;
+  box-shadow: 0 5px 8px 0px #00000010;
+  cursor: ${(props: { disabled?: boolean }) =>
+    props.disabled ? "not-allowed" : "pointer"};
+  background: ${(props: { disabled?: boolean }) =>
+    props.disabled ? "#aaaabbee" : "#5561C0"};
+  :hover {
+    filter: ${(props) => (!props.disabled ? "brightness(120%)" : "")};
+  }
+  > i {
+    color: white;
+    width: 18px;
+    height: 18px;
+    font-weight: 600;
+    font-size: 12px;
+    border-radius: 20px;
+    display: flex;
+    align-items: center;
+    margin-right: 5px;
+    justify-content: center;
+  }
+`;
+
+const Placeholder = styled.div`
+  padding: 30px;
+  margin-top: 35px;
+  padding-bottom: 40px;
+  font-size: 13px;
+  color: #ffffff44;
+  min-height: 400px;
+  height: 50vh;
+  background: #ffffff11;
+  border-radius: 8px;
+  display: flex;
+  align-items: left;
+  justify-content: center;
+  flex-direction: column;
+
+  > i {
+    font-size: 18px;
+    margin-right: 8px;
+  }
+`;
+
+const AgentButtonContainer = styled.div`
+  display: flex;
+  align-items: left;
+  justify-content: center;
+  flex-direction: column;
+  width: 500px;
+  margin: 0 auto;
+`;
+
+const Header = styled.div`
+  font-weight: 500;
+  color: #aaaabb;
+  font-size: 16px;
+  margin-bottom: 15px;
+`;
+
+const Subheader = styled.div``;

+ 217 - 0
dashboard/src/main/home/cluster-dashboard/expanded-chart/incidents/IncidentsTable.tsx

@@ -0,0 +1,217 @@
+import Table from "components/Table";
+import React, { useContext, useEffect, useMemo, useState } from "react";
+import { useLocation } from "react-router";
+import { Column } from "react-table";
+import api from "shared/api";
+import { Context } from "shared/Context";
+import { hardcodedIcons } from "shared/hardcodedNameDict";
+import { useRouting } from "shared/routing";
+import { capitalize } from "shared/string_utils";
+import styled from "styled-components";
+import { dateFormatter } from "../../chart/JobRunTable";
+import { IncidentsWithoutEvents } from "../../dashboard/incidents/IncidentsTable";
+
+const IncidentsTable = ({
+  releaseName,
+  namespace,
+}: {
+  releaseName: string;
+  namespace: string;
+}) => {
+  const { currentCluster, currentProject, setCurrentError } = useContext(
+    Context
+  );
+  const { pushFiltered } = useRouting();
+  const location = useLocation();
+
+  const [incidents, setIncidents] = useState<IncidentsWithoutEvents[]>(null);
+  const [hasError, setHasError] = useState(false);
+  const [isRefreshing, setIsRefreshing] = useState(false);
+
+  useEffect(() => {
+    let isSubscribed = true;
+    setIncidents(null);
+    setHasError(false);
+
+    api
+      .getIncidentsByReleaseName<{ incidents: IncidentsWithoutEvents[] }>(
+        "<token>",
+        {
+          namespace: namespace,
+          release_name: releaseName,
+        },
+        {
+          project_id: currentProject.id,
+          cluster_id: currentCluster.id,
+        }
+      )
+      .then((res) => {
+        if (!isSubscribed) {
+          return;
+        }
+
+        setIncidents(res.data?.incidents || []);
+      })
+      .catch((err) => {
+        setHasError(true);
+        setCurrentError(err);
+      });
+
+    return () => {
+      isSubscribed = false;
+    };
+  }, [currentCluster, currentProject]);
+
+  const refreshIncidents = async () => {
+    setIsRefreshing(true);
+    try {
+      const incidents = await api
+        .getIncidentsByReleaseName<{ incidents: IncidentsWithoutEvents[] }>(
+          "<token>",
+          {
+            namespace: namespace,
+            release_name: releaseName,
+          },
+          {
+            project_id: currentProject.id,
+            cluster_id: currentCluster.id,
+          }
+        )
+        .then((res) => res.data?.incidents || []);
+
+      setIncidents(incidents);
+    } catch (err) {
+      setHasError(true);
+      setCurrentError(err);
+    } finally {
+      setIsRefreshing(false);
+    }
+  };
+
+  const columns = useMemo(() => {
+    return [
+      {
+        Header: "Status",
+        accessor: "latest_state",
+        Cell: ({ row }) => {
+          let original = row.original;
+
+          return (
+            <Status>
+              <StatusDot status={original.latest_state} />
+              {capitalize(original.latest_state)}
+            </Status>
+          );
+        },
+      },
+      {
+        Header: "Message",
+        accessor: "latest_message",
+        Cell: ({ row }) => {
+          let original = row.original;
+
+          return <Message>{original.latest_message}</Message>;
+        },
+      },
+      {
+        Header: "Started",
+        accessor: "created_at",
+        Cell: ({ row }) => {
+          let original = row.original;
+
+          return dateFormatter(original.created_at * 1000);
+        },
+      },
+      {
+        Header: "Last Updated",
+        accessor: "updated_at",
+        Cell: ({ row }) => {
+          let original = row.original;
+
+          return dateFormatter(original.updated_at * 1000);
+        },
+      },
+    ] as Column<IncidentsWithoutEvents>[];
+  }, []);
+
+  const data = useMemo(() => {
+    if (!incidents) {
+      return [];
+    }
+    return incidents;
+  }, [incidents]);
+
+  return (
+    <Table
+      columns={columns}
+      data={data}
+      isLoading={incidents === null}
+      onRowClick={(row: any) => {
+        pushFiltered(`/cluster-dashboard/incidents/${row?.original?.id}/`, [], {
+          redirect_url: location.pathname,
+        });
+      }}
+      hasError={hasError}
+      onRefresh={refreshIncidents}
+      isRefreshing={isRefreshing}
+    />
+  );
+};
+
+export default IncidentsTable;
+
+const TableWrapper = styled.div``;
+
+const StyledCard = styled.div`
+  background: #26282f;
+  padding: 14px;
+  border-radius: 8px;
+  box-shadow: 0 4px 15px 0px #00000055;
+  position: relative;
+  border: 2px solid #9eb4ff00;
+  width: 100%;
+  height: 100%;
+  :not(:last-child) {
+    margin-bottom: 25px;
+  }
+`;
+
+const KindContainer = styled.div`
+  display: flex;
+  align-items: center;
+  min-width: 200px;
+`;
+
+const Kind = styled.div`
+  margin-left: 8px;
+`;
+
+const Icon = styled.img`
+  height: 20px;
+`;
+
+const Status = styled.span`
+  font-size: 13px;
+  display: flex;
+  align-items: center;
+  margin-left: 1px;
+  min-height: 17px;
+  color: #a7a6bb;
+`;
+
+const StatusDot = styled.div`
+  width: 8px;
+  height: 8px;
+  background: ${(props: { status: string }) =>
+    props.status === "ONGOING" ? "#ed5f85" : "#4797ff"};
+  border-radius: 20px;
+  margin-left: 3px;
+  margin-right: 15px;
+`;
+
+const Message = styled.div`
+  white-space: nowrap;
+  overflow-x: hidden;
+  text-overflow: ellipsis;
+  max-width: 500px;
+`;

+ 82 - 20
dashboard/src/shared/api.tsx

@@ -1,3 +1,4 @@
+import { release } from "process";
 import { baseApi } from "./baseApi";
 
 import { FullActionConfigType, StorageType } from "./types";
@@ -444,11 +445,9 @@ const detectBuildpack = baseApi<
     branch: string;
   }
 >("GET", (pathParams) => {
-  return `/api/projects/${pathParams.project_id}/gitrepos/${
-    pathParams.git_repo_id
-  }/repos/${pathParams.kind}/${pathParams.owner}/${
-    pathParams.name
-  }/${encodeURIComponent(pathParams.branch)}/buildpack/detect`;
+  return `/api/projects/${pathParams.project_id}/gitrepos/${pathParams.git_repo_id
+    }/repos/${pathParams.kind}/${pathParams.owner}/${pathParams.name
+    }/${encodeURIComponent(pathParams.branch)}/buildpack/detect`;
 });
 
 const getBranchContents = baseApi<
@@ -464,11 +463,9 @@ const getBranchContents = baseApi<
     branch: string;
   }
 >("GET", (pathParams) => {
-  return `/api/projects/${pathParams.project_id}/gitrepos/${
-    pathParams.git_repo_id
-  }/repos/${pathParams.kind}/${pathParams.owner}/${
-    pathParams.name
-  }/${encodeURIComponent(pathParams.branch)}/contents`;
+  return `/api/projects/${pathParams.project_id}/gitrepos/${pathParams.git_repo_id
+    }/repos/${pathParams.kind}/${pathParams.owner}/${pathParams.name
+    }/${encodeURIComponent(pathParams.branch)}/contents`;
 });
 
 const getProcfileContents = baseApi<
@@ -484,11 +481,9 @@ const getProcfileContents = baseApi<
     branch: string;
   }
 >("GET", (pathParams) => {
-  return `/api/projects/${pathParams.project_id}/gitrepos/${
-    pathParams.git_repo_id
-  }/repos/${pathParams.kind}/${pathParams.owner}/${
-    pathParams.name
-  }/${encodeURIComponent(pathParams.branch)}/procfile`;
+  return `/api/projects/${pathParams.project_id}/gitrepos/${pathParams.git_repo_id
+    }/repos/${pathParams.kind}/${pathParams.owner}/${pathParams.name
+    }/${encodeURIComponent(pathParams.branch)}/procfile`;
 });
 
 const getBranches = baseApi<
@@ -1220,11 +1215,9 @@ const getEnvGroup = baseApi<
     version?: number;
   }
 >("GET", (pathParams) => {
-  return `/api/projects/${pathParams.id}/clusters/${
-    pathParams.cluster_id
-  }/namespaces/${pathParams.namespace}/envgroup?name=${pathParams.name}${
-    pathParams.version ? "&version=" + pathParams.version : ""
-  }`;
+  return `/api/projects/${pathParams.id}/clusters/${pathParams.cluster_id
+    }/namespaces/${pathParams.namespace}/envgroup?name=${pathParams.name}${pathParams.version ? "&version=" + pathParams.version : ""
+    }`;
 });
 
 const getConfigMap = baseApi<
@@ -1610,6 +1603,70 @@ const getPreviousLogsForContainer = baseApi<
     `/api/projects/${project_id}/clusters/${cluster_id}/namespaces/${namespace}/pod/${name}/previous_logs`
 );
 
+const getIncidents = baseApi<
+  {},
+  {
+    project_id: number;
+    cluster_id: number;
+  }
+>(
+  "GET",
+  ({ project_id, cluster_id }) =>
+    `/api/projects/${project_id}/clusters/${cluster_id}/incidents`
+);
+
+const getIncidentsByReleaseName = baseApi<
+  {
+    namespace: string;
+    release_name: string;
+  },
+  {
+    project_id: number;
+    cluster_id: number;
+  }
+>(
+  "GET",
+  ({ project_id, cluster_id }) =>
+    `/api/projects/${project_id}/clusters/${cluster_id}/incidents`
+);
+
+const getIncidentById = baseApi<
+  {
+    incident_id: string;
+  },
+  {
+    project_id: number;
+    cluster_id: number;
+  }
+>(
+  "GET",
+  ({ project_id, cluster_id }) =>
+    `/api/projects/${project_id}/clusters/${cluster_id}/incidents`
+);
+
+const getIncidentLogsByLogId = baseApi<
+  {
+    log_id: string;
+  },
+  {
+    project_id: number;
+    cluster_id: number;
+  }
+>(
+  "GET",
+  ({ project_id, cluster_id }) =>
+    `/api/projects/${project_id}/clusters/${cluster_id}/incidents/logs`
+);
+
+const upgradePorterAgent = baseApi<
+  {},
+  { project_id: number; cluster_id: number }
+>(
+  "POST",
+  ({ project_id, cluster_id }) =>
+    `/api/projects/${project_id}/clusters/${cluster_id}/agent/upgrade`
+);
+
 // Bundle export to allow default api import (api.<method> is more readable)
 export default {
   checkAuth,
@@ -1763,5 +1820,10 @@ export default {
   provisionDatabase,
   getDatabases,
   getPreviousLogsForContainer,
+  getIncidents,
+  getIncidentsByReleaseName,
+  getIncidentById,
+  getIncidentLogsByLogId,
+  upgradePorterAgent,
   deletePRDeployment,
 };

+ 1 - 1
go.mod

@@ -67,6 +67,7 @@ require (
 )
 
 require (
+	github.com/briandowns/spinner v1.18.1
 	gopkg.in/segmentio/analytics-go.v3 v3.1.0
 	gopkg.in/yaml.v2 v2.4.0
 	gorm.io/driver/postgres v1.2.3
@@ -94,7 +95,6 @@ require (
 	github.com/asaskevich/govalidator v0.0.0-20200428143746-21a406dcc535 // indirect
 	github.com/beorn7/perks v1.0.1 // indirect
 	github.com/bits-and-blooms/bitset v1.2.0 // indirect
-	github.com/briandowns/spinner v1.18.1 // indirect
 	github.com/buildpacks/imgutil v0.0.0-20210510154637-009f91f52918 // indirect
 	github.com/buildpacks/lifecycle v0.11.3 // indirect
 	github.com/census-instrumentation/opencensus-proto v0.3.0 // indirect

+ 0 - 4
go.sum

@@ -431,8 +431,6 @@ github.com/dgryski/go-farm v0.0.0-20190423205320-6a90982ecee2/go.mod h1:SqUrOPUn
 github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78=
 github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
 github.com/dgryski/go-sip13 v0.0.0-20181026042036-e10d5fee7954/go.mod h1:vAd38F8PWV+bWy6jNmig1y/TA+kYO4g3RSRF0IAv0no=
-github.com/digitalocean/godo v1.56.0 h1:wXqWJyywrDO3YO2T4Kh8TwbCPOa+OI2vC8qh0/Ngmjk=
-github.com/digitalocean/godo v1.56.0/go.mod h1:p7dOjjtSBqCTUksqtA5Fd3uaKs9kyTq2xcz76ulEJRU=
 github.com/digitalocean/godo v1.75.0 h1:UijUv60I095CqJqGKdjY2RTPnnIa4iFddmq+1wfyS4Y=
 github.com/digitalocean/godo v1.75.0/go.mod h1:GBmu8MkjZmNARE7IXRPmkbbnocNN8+uBm0xbEVw2LCs=
 github.com/distribution/distribution/v3 v3.0.0-20211118083504-a29a3c99a684 h1:DBZ2sN7CK6dgvHVpQsQj4sRMCbWTmd17l+5SUCjnQSY=
@@ -1219,8 +1217,6 @@ github.com/pkg/sftp v1.10.1/go.mod h1:lYOWFsE0bwd1+KfKJaKeuokY15vzFx25BLbzYYoAxZ
 github.com/pmezard/go-difflib v0.0.0-20151028094244-d8ed2627bdf0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
 github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
 github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
-github.com/porter-dev/switchboard v0.0.0-20220109170702-ea2a4450e034 h1:qzxRAL/HPfadofm5CX3zG3aPXOH77W3KwiW/zctUF7c=
-github.com/porter-dev/switchboard v0.0.0-20220109170702-ea2a4450e034/go.mod h1:xSPzqSFMQ6OSbp42fhCi4AbGbQbsm6nRvOkrblFeXU4=
 github.com/porter-dev/switchboard v0.0.0-20220209153113-9d257b8e0dfb h1:aNRIZcKkDkFhyROzmc5FCHgK6+ZbmzfTGudioPdtgmU=
 github.com/porter-dev/switchboard v0.0.0-20220209153113-9d257b8e0dfb/go.mod h1:xSPzqSFMQ6OSbp42fhCi4AbGbQbsm6nRvOkrblFeXU4=
 github.com/posener/complete v1.1.1/go.mod h1:em0nMJCgc9GFtwrmVmEMR/ZL6WyhyjMBndrE9hABlRI=

+ 135 - 0
internal/integrations/slack/incidents_notifier.go

@@ -0,0 +1,135 @@
+package slack
+
+import (
+	"bytes"
+	"encoding/json"
+	"fmt"
+	"net/http"
+	"strings"
+	"time"
+
+	"github.com/porter-dev/porter/api/types"
+	porter_agent "github.com/porter-dev/porter/internal/kubernetes/porter_agent/v2"
+	"github.com/porter-dev/porter/internal/models/integrations"
+)
+
+type IncidentsNotifier struct {
+	slackInts []*integrations.SlackIntegration
+	Config    *types.NotificationConfig
+}
+
+func NewIncidentsNotifier(conf *types.NotificationConfig, slackInts ...*integrations.SlackIntegration) *IncidentsNotifier {
+	return &IncidentsNotifier{
+		slackInts: slackInts,
+		Config:    conf,
+	}
+}
+
+func (s *IncidentsNotifier) NotifyNew(incident *porter_agent.Incident, url string) error {
+	res := []*SlackBlock{}
+
+	topSectionMarkdwn := fmt.Sprintf(
+		":warning: Your application %s crashed on Porter. <%s|View the incident.>",
+		"`"+incident.ReleaseName+"`",
+		url,
+	)
+
+	namespace := strings.Split(incident.ID, ":")[2]
+	createdAt := time.Unix(incident.CreatedAt, 0).UTC()
+
+	res = append(
+		res,
+		getMarkdownBlock(topSectionMarkdwn),
+		getDividerBlock(),
+		getMarkdownBlock(fmt.Sprintf("*Namespace:* %s", "`"+namespace+"`")),
+		getMarkdownBlock(fmt.Sprintf("*Name:* %s", "`"+incident.ReleaseName+"`")),
+		getMarkdownBlock(fmt.Sprintf(
+			"*Created at:* <!date^%d^ {date_num} {time_secs}| %s>",
+			createdAt.Unix(),
+			createdAt.Format("2006-01-02 15:04:05 UTC"),
+		)),
+		getMarkdownBlock(fmt.Sprintf("```\n%s\n```", incident.LatestMessage)),
+	)
+
+	slackPayload := &SlackPayload{
+		Blocks: res,
+	}
+
+	payload, err := json.Marshal(slackPayload)
+
+	if err != nil {
+		return err
+	}
+
+	reqBody := bytes.NewReader(payload)
+	client := &http.Client{
+		Timeout: time.Second * 5,
+	}
+
+	for _, slackInt := range s.slackInts {
+		_, err := client.Post(string(slackInt.Webhook), "application/json", reqBody)
+
+		if err != nil {
+			return err
+		}
+	}
+
+	return nil
+}
+
+func (s *IncidentsNotifier) NotifyResolved(incident *porter_agent.Incident, url string) error {
+	res := []*SlackBlock{}
+
+	namespace := strings.Split(incident.ID, ":")[2]
+	createdAt := time.Unix(incident.CreatedAt, 0).UTC()
+	resolvedAt := time.Unix(incident.UpdatedAt, 0).UTC()
+
+	topSectionMarkdwn := fmt.Sprintf(
+		":white_check_mark: The incident for application %s has been resolved. <%s|View the incident.>",
+		"`"+incident.ReleaseName+"`",
+		url,
+	)
+
+	res = append(
+		res,
+		getMarkdownBlock(topSectionMarkdwn),
+		getDividerBlock(),
+		getMarkdownBlock(fmt.Sprintf("*Namespace:* %s", "`"+namespace+"`")),
+		getMarkdownBlock(fmt.Sprintf("*Name:* %s", "`"+incident.ReleaseName+"`")),
+		getMarkdownBlock(fmt.Sprintf(
+			"*Created at:* <!date^%d^ {date_num} {time_secs}| %s>",
+			createdAt.Unix(),
+			createdAt.Format("2006-01-02 15:04:05 UTC"),
+		)),
+		getMarkdownBlock(fmt.Sprintf(
+			"*Resolved at:* <!date^%d^ {date_num} {time_secs}| %s>",
+			resolvedAt.Unix(),
+			resolvedAt.Format("2006-01-02 15:04:05 UTC"),
+		)),
+	)
+
+	slackPayload := &SlackPayload{
+		Blocks: res,
+	}
+
+	payload, err := json.Marshal(slackPayload)
+
+	if err != nil {
+		return err
+	}
+
+	reqBody := bytes.NewReader(payload)
+	client := &http.Client{
+		Timeout: time.Second * 5,
+	}
+
+	for _, slackInt := range s.slackInts {
+		_, err := client.Post(string(slackInt.Webhook), "application/json", reqBody)
+
+		if err != nil {
+			return err
+		}
+	}
+
+	return nil
+}

+ 132 - 0
internal/kubernetes/porter_agent/v2/agent_server.go

@@ -0,0 +1,132 @@
+package v2
+
+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{},
+	)
+}
+
+func GetAllIncidents(
+	clientset kubernetes.Interface,
+	service *v1.Service,
+) (*IncidentsResponse, error) {
+	resp := clientset.CoreV1().Services(service.Namespace).ProxyGet(
+		"http",
+		service.Name,
+		fmt.Sprintf("%d", service.Spec.Ports[0].Port),
+		"/incidents",
+		nil,
+	)
+
+	rawQuery, err := resp.DoRaw(context.Background())
+	if err != nil {
+		return nil, err
+	}
+
+	incidentsResp := &IncidentsResponse{}
+
+	err = json.Unmarshal(rawQuery, incidentsResp)
+	if err != nil {
+		return nil, err
+	}
+
+	return incidentsResp, nil
+}
+
+func GetIncidentEventsByID(
+	clientset kubernetes.Interface,
+	service *v1.Service,
+	incidentID string,
+) (*EventsResponse, error) {
+	resp := clientset.CoreV1().Services(service.Namespace).ProxyGet(
+		"http",
+		service.Name,
+		fmt.Sprintf("%d", service.Spec.Ports[0].Port),
+		fmt.Sprintf("/incidents/%s", incidentID),
+		nil,
+	)
+
+	rawQuery, err := resp.DoRaw(context.Background())
+	if err != nil {
+		return nil, err
+	}
+
+	eventsResp := &EventsResponse{}
+
+	err = json.Unmarshal(rawQuery, eventsResp)
+	if err != nil {
+		return nil, err
+	}
+
+	return eventsResp, nil
+}
+
+func GetIncidentsByReleaseNamespace(
+	clientset kubernetes.Interface,
+	service *v1.Service,
+	releaseName, namespace string,
+) (*IncidentsResponse, error) {
+	resp := clientset.CoreV1().Services(service.Namespace).ProxyGet(
+		"http",
+		service.Name,
+		fmt.Sprintf("%d", service.Spec.Ports[0].Port),
+		fmt.Sprintf("/incidents/namespaces/%s/releases/%s", namespace, releaseName),
+		nil,
+	)
+
+	rawQuery, err := resp.DoRaw(context.Background())
+	if err != nil {
+		return nil, err
+	}
+
+	incidentsResp := &IncidentsResponse{}
+
+	err = json.Unmarshal(rawQuery, incidentsResp)
+	if err != nil {
+		return nil, err
+	}
+
+	return incidentsResp, nil
+}
+
+func GetLogs(
+	clientset kubernetes.Interface,
+	service *v1.Service,
+	logID string,
+) (*LogsResponse, error) {
+	resp := clientset.CoreV1().Services(service.Namespace).ProxyGet(
+		"http",
+		service.Name,
+		fmt.Sprintf("%d", service.Spec.Ports[0].Port),
+		fmt.Sprintf("/incidents/logs/%s", logID),
+		nil,
+	)
+
+	rawQuery, err := resp.DoRaw(context.Background())
+	if err != nil {
+		return nil, err
+	}
+
+	logsResp := &LogsResponse{}
+
+	err = json.Unmarshal(rawQuery, logsResp)
+	if err != nil {
+		return nil, err
+	}
+
+	return logsResp, nil
+}

+ 55 - 0
internal/kubernetes/porter_agent/v2/models.go

@@ -0,0 +1,55 @@
+package v2
+
+type ContainerEvent struct {
+	Name     string `json:"container_name"`
+	Reason   string `json:"reason"`
+	Message  string `json:"message"`
+	LogID    string `json:"log_id"`
+	ExitCode int32  `json:"exit_code"`
+}
+
+type PodEvent struct {
+	EventID         string                     `json:"event_id"`
+	PodName         string                     `json:"pod_name"`
+	Namespace       string                     `json:"namespace"`
+	Cluster         string                     `json:"cluster"`
+	OwnerName       string                     `json:"release_name"`
+	OwnerType       string                     `json:"release_type"`
+	Timestamp       int64                      `json:"timestamp"`
+	Phase           string                     `json:"pod_phase"`
+	Status          string                     `json:"pod_status"`
+	Reason          string                     `json:"reason"`
+	Message         string                     `json:"message"`
+	ContainerEvents map[string]*ContainerEvent `json:"container_events"`
+}
+
+type Incident struct {
+	ID            string `json:"id" form:"required"`
+	ReleaseName   string `json:"release_name" form:"required"`
+	ChartName     string `json:"chart_name"`
+	CreatedAt     int64  `json:"created_at" form:"required"`
+	UpdatedAt     int64  `json:"updated_at" form:"required"`
+	LatestState   string `json:"latest_state" form:"required"`
+	LatestReason  string `json:"latest_reason" form:"required"`
+	LatestMessage string `json:"latest_message" form:"required"`
+}
+
+type IncidentsResponse struct {
+	Incidents []*Incident `json:"incidents" form:"required"`
+}
+
+type EventsResponse struct {
+	IncidentID    string      `json:"incident_id" form:"required"`
+	ChartName     string      `json:"chart_name"`
+	ReleaseName   string      `json:"release_name"`
+	CreatedAt     int64       `json:"created_at"`
+	UpdatedAt     int64       `json:"updated_at"`
+	LatestState   string      `json:"latest_state"`
+	LatestReason  string      `json:"latest_reason"`
+	LatestMessage string      `json:"latest_message"`
+	Events        []*PodEvent `json:"events" form:"required"`
+}
+
+type LogsResponse struct {
+	Contents string `json:"contents" form:"required"`
+}