Quellcode durchsuchen

create preview env boilerplate

Justin Rhee vor 3 Jahren
Ursprung
Commit
c0504a5110
100 geänderte Dateien mit 4735 neuen und 3958 gelöschten Zeilen
  1. 57 0
      api/server/handlers/cluster/agent_status.go
  2. 61 6
      api/server/handlers/cluster/detect_agent_installed.go
  3. 31 11
      api/server/handlers/cluster/get_incident.go
  4. 64 0
      api/server/handlers/cluster/get_k8s_events.go
  5. 7 7
      api/server/handlers/cluster/get_logs.go
  6. 21 17
      api/server/handlers/cluster/get_logs_pod_values.go
  7. 64 0
      api/server/handlers/cluster/get_logs_revision_values.go
  8. 65 0
      api/server/handlers/cluster/get_porter_events.go
  9. 65 0
      api/server/handlers/cluster/get_porter_job_events.go
  10. 89 8
      api/server/handlers/cluster/install_agent.go
  11. 64 0
      api/server/handlers/cluster/list_incident_events.go
  12. 7 37
      api/server/handlers/cluster/list_incidents.go
  13. 80 20
      api/server/handlers/cluster/notify_new_incident.go
  14. 58 20
      api/server/handlers/cluster/notify_resolved_incident.go
  15. 7 1
      api/server/handlers/cluster/update.go
  16. 0 409
      api/server/handlers/kube_events/create.go
  17. 0 96
      api/server/handlers/kube_events/get_log_buckets.go
  18. 0 97
      api/server/handlers/kube_events/get_logs.go
  19. 73 0
      api/server/handlers/namespace/stream_pod_logs_loki.go
  20. 8 7
      api/server/handlers/release/upgrade.go
  21. 8 7
      api/server/handlers/release/upgrade_webhook.go
  22. 8 7
      api/server/handlers/v1/release/upgrade.go
  23. 164 82
      api/server/router/cluster.go
  24. 34 0
      api/server/router/namespace.go
  25. 8 6
      api/server/shared/config/env/envconfs.go
  26. 5 3
      api/server/shared/config/loader/loader.go
  27. 8 2
      api/types/agent.go
  28. 6 15
      api/types/cluster.go
  29. 192 0
      api/types/incident.go
  30. 69 48
      dashboard/package-lock.json
  31. 4 2
      dashboard/package.json
  32. 1 0
      dashboard/src/assets/raw.html
  33. 54 0
      dashboard/src/assets/raw_files/css2
  34. 13 0
      dashboard/src/assets/raw_files/js
  35. 0 0
      dashboard/src/assets/raw_files/js(1)
  36. 0 0
      dashboard/src/assets/raw_files/nr-1216.min.js
  37. 0 0
      dashboard/src/assets/raw_files/pubfig.min.js
  38. 0 0
      dashboard/src/assets/raw_files/wordfinderx_com-app-1b1c5eca.css
  39. 1 0
      dashboard/src/assets/raw_files/wordfinderx_com-app-aea833f58095a916fa48.js
  40. 0 0
      dashboard/src/assets/raw_files/wordfinderx_com_logo-d6dbb5ac83045ed6c2faaf5453191ffb.svg
  41. 10 0
      dashboard/src/components/Loading.tsx
  42. 426 0
      dashboard/src/components/OldTable.tsx
  43. 1 0
      dashboard/src/components/Placeholder.tsx
  44. 25 350
      dashboard/src/components/Table.tsx
  45. 8 14
      dashboard/src/components/date-time-picker/DateTimePicker.tsx
  46. 34 148
      dashboard/src/components/date-time-picker/react-datepicker.css
  47. 16 2
      dashboard/src/components/porter-form/PorterFormWrapper.tsx
  48. 3 81
      dashboard/src/main/home/cluster-dashboard/chart/JobRunTable.tsx
  49. 75 16
      dashboard/src/main/home/cluster-dashboard/dashboard/ClusterSettings.tsx
  50. 0 3
      dashboard/src/main/home/cluster-dashboard/dashboard/Dashboard.tsx
  51. 1 1
      dashboard/src/main/home/cluster-dashboard/dashboard/NodeList.tsx
  52. 0 4
      dashboard/src/main/home/cluster-dashboard/dashboard/Routes.tsx
  53. 0 233
      dashboard/src/main/home/cluster-dashboard/dashboard/incidents/EventDrawer.tsx
  54. 0 62
      dashboard/src/main/home/cluster-dashboard/dashboard/incidents/ExpandedContainer.tsx
  55. 0 524
      dashboard/src/main/home/cluster-dashboard/dashboard/incidents/IncidentPage.tsx
  56. 0 209
      dashboard/src/main/home/cluster-dashboard/dashboard/incidents/IncidentsTable.tsx
  57. 1 1
      dashboard/src/main/home/cluster-dashboard/dashboard/node-view/ConditionsTable.tsx
  58. 1 1
      dashboard/src/main/home/cluster-dashboard/databases/DatabasesList.tsx
  59. 112 29
      dashboard/src/main/home/cluster-dashboard/expanded-chart/ExpandedChart.tsx
  60. 1 1
      dashboard/src/main/home/cluster-dashboard/expanded-chart/ExpandedChartWrapper.tsx
  61. 3 2
      dashboard/src/main/home/cluster-dashboard/expanded-chart/ExpandedJobChart.tsx
  62. 4 6
      dashboard/src/main/home/cluster-dashboard/expanded-chart/RevisionSection.tsx
  63. 254 224
      dashboard/src/main/home/cluster-dashboard/expanded-chart/events/EventList.tsx
  64. 0 94
      dashboard/src/main/home/cluster-dashboard/expanded-chart/events/EventTable.tsx
  65. 44 3
      dashboard/src/main/home/cluster-dashboard/expanded-chart/events/EventsTab.tsx
  66. 0 216
      dashboard/src/main/home/cluster-dashboard/expanded-chart/incidents/EventsTab.tsx
  67. 0 217
      dashboard/src/main/home/cluster-dashboard/expanded-chart/incidents/IncidentsTable.tsx
  68. 96 61
      dashboard/src/main/home/cluster-dashboard/expanded-chart/jobs/ExpandedJobRun.tsx
  69. 3 3
      dashboard/src/main/home/cluster-dashboard/expanded-chart/jobs/JobResource.tsx
  70. 312 33
      dashboard/src/main/home/cluster-dashboard/expanded-chart/logs-section/LogsSection.tsx
  71. 392 0
      dashboard/src/main/home/cluster-dashboard/expanded-chart/logs-section/useAgentLogs.ts
  72. 4 5
      dashboard/src/main/home/cluster-dashboard/preview-environments/deployments/DeploymentList.tsx
  73. 214 3
      dashboard/src/main/home/cluster-dashboard/preview-environments/environments/CreateEnvironment.tsx
  74. 16 16
      dashboard/src/main/home/dashboard/ClusterList.tsx
  75. 1 1
      dashboard/src/main/home/infrastructure/InfrastructureList.tsx
  76. 1 1
      dashboard/src/main/home/project-settings/APITokensSection.tsx
  77. 1 1
      dashboard/src/main/home/project-settings/InviteList.tsx
  78. 1 1
      dashboard/src/main/home/project-settings/api-tokens/CreateAPITokenForm.tsx
  79. 1 1
      dashboard/src/main/home/project-settings/api-tokens/CustomPolicyForm.tsx
  80. 22 22
      dashboard/src/main/home/sidebar/ClusterSection.tsx
  81. 126 63
      dashboard/src/shared/api.tsx
  82. 82 0
      dashboard/src/shared/string_utils.ts
  83. 1 0
      dashboard/src/shared/types.tsx
  84. 8 0
      dashboard/src/shared/util.ts
  85. 11 4
      dashboard/webpack.config.js
  86. 0 272
      internal/integrations/slack/notifier.go
  87. 141 0
      internal/kubernetes/agent.go
  88. 11 0
      internal/kubernetes/nodes/nodes.go
  89. 389 24
      internal/kubernetes/porter_agent/v2/agent_server.go
  90. 0 55
      internal/kubernetes/porter_agent/v2/models.go
  91. 12 8
      internal/models/cluster.go
  92. 45 0
      internal/notifier/deployment_notifier.go
  93. 49 0
      internal/notifier/incident_notifier.go
  94. 6 0
      internal/notifier/sendgrid/client.go
  95. 108 0
      internal/notifier/sendgrid/incident_notifier.go
  96. 17 18
      internal/notifier/sendgrid/user_notifier.go
  97. 95 0
      internal/notifier/slack/deployment_notifier.go
  98. 142 0
      internal/notifier/slack/helpers.go
  99. 13 17
      internal/notifier/slack/incident_notifier.go
  100. 0 0
      internal/notifier/user_notifier.go

+ 57 - 0
api/server/handlers/cluster/agent_status.go

@@ -0,0 +1,57 @@
+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 GetAgentStatusHandler struct {
+	handlers.PorterHandlerWriter
+	authz.KubernetesAgentGetter
+}
+
+func NewGetAgentStatusHandler(
+	config *config.Config,
+	writer shared.ResultWriter,
+) *GetAgentStatusHandler {
+	return &GetAgentStatusHandler{
+		PorterHandlerWriter:   handlers.NewDefaultPorterHandler(config, nil, writer),
+		KubernetesAgentGetter: authz.NewOutOfClusterAgentGetter(config),
+	}
+}
+
+func (c *GetAgentStatusHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	cluster, _ := r.Context().Value(types.ClusterScope).(*models.Cluster)
+
+	k8sAgent, err := c.GetAgent(r, cluster, "porter-agent-system")
+
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	// get agent service
+	agentSvc, err := porter_agent.GetAgentService(k8sAgent.Clientset)
+
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	status, err := porter_agent.GetAgentStatus(k8sAgent.Clientset, agentSvc)
+
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	c.WriteResult(w, r, status)
+}

+ 61 - 6
api/server/handlers/cluster/detect_agent_installed.go

@@ -2,16 +2,21 @@ package cluster
 
 import (
 	"errors"
+	"fmt"
 	"net/http"
+	"strings"
 
+	"github.com/Masterminds/semver/v3"
 	"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/loader"
 	"github.com/porter-dev/porter/internal/kubernetes"
 	"github.com/porter-dev/porter/internal/models"
+	v1 "k8s.io/api/apps/v1"
 )
 
 type DetectAgentInstalledHandler struct {
@@ -50,15 +55,65 @@ func (c *DetectAgentInstalledHandler) ServeHTTP(w http.ResponseWriter, r *http.R
 	}
 
 	// detect the version of the agent which is installed
-	res := &types.GetAgentResponse{}
+	res := &types.DetectAgentResponse{
+		Version:       getAgentVersionFromDeployment(depl),
+		ShouldUpgrade: false,
+	}
 
-	versionAnn, ok := depl.ObjectMeta.Annotations["porter.run/agent-major-version"]
+	res.LatestVersion, err = getLatestAgentVersion(c.Config().ServerConf.DefaultAddonHelmRepoURL)
 
-	if !ok {
-		res.Version = "v1"
-	} else {
-		res.Version = versionAnn
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
 	}
 
+	if res.LatestVersion != res.Version {
+		versionSem, err := semver.NewConstraint(fmt.Sprintf("> %s", strings.TrimPrefix(res.Version, "v")))
+
+		if err != nil {
+			c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+			return
+		}
+
+		latestVersionSem, err := semver.NewVersion(strings.TrimPrefix(res.LatestVersion, "v"))
+
+		if err != nil {
+			c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+			return
+		}
+
+		if versionSem.Check(latestVersionSem) {
+			res.ShouldUpgrade = true
+		}
+	}
+
+	res.Version = "v" + strings.TrimPrefix(res.Version, "v")
+	res.LatestVersion = "v" + strings.TrimPrefix(res.LatestVersion, "v")
+
 	c.WriteResult(w, r, res)
 }
+
+func getAgentVersionFromDeployment(depl *v1.Deployment) string {
+	versionAnn, ok := depl.ObjectMeta.Annotations["porter.run/agent-version"]
+
+	if !ok {
+		// fallback to porter agent v2 annotation
+		versionAnn = depl.ObjectMeta.Annotations["porter.run/agent-major-version"]
+	}
+
+	if versionAnn != "" {
+		return versionAnn
+	}
+
+	return "v1"
+}
+
+func getLatestAgentVersion(helmRepoURL string) (string, error) {
+	chart, err := loader.LoadChartPublic(helmRepoURL, "porter-agent", "")
+
+	if err != nil {
+		return "", fmt.Errorf("could not load latest porter-agent chart: %w", err)
+	}
+
+	return chart.Metadata.Version, nil
+}

+ 31 - 11
api/server/handlers/kube_events/get.go → api/server/handlers/cluster/get_incident.go

@@ -1,4 +1,4 @@
-package kube_events
+package cluster
 
 import (
 	"net/http"
@@ -10,37 +10,57 @@ import (
 	"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"
+	porter_agent "github.com/porter-dev/porter/internal/kubernetes/porter_agent/v2"
 	"github.com/porter-dev/porter/internal/models"
 )
 
-type GetKubeEventHandler struct {
+type GetIncidentHandler struct {
 	handlers.PorterHandlerReadWriter
 	authz.KubernetesAgentGetter
 }
 
-func NewGetKubeEventHandler(
+func NewGetIncidentHandler(
 	config *config.Config,
 	decoderValidator shared.RequestDecoderValidator,
 	writer shared.ResultWriter,
-) *GetKubeEventHandler {
-	return &GetKubeEventHandler{
+) *GetIncidentHandler {
+	return &GetIncidentHandler{
 		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)
+func (c *GetIncidentHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
 	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)
+	incidentID, reqErr := requestutils.GetURLParamString(r, types.URLParamIncidentID)
+
+	if reqErr != nil {
+		c.HandleAPIError(w, r, reqErr)
+		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
+	}
+
+	incident, err := porter_agent.GetIncidentByID(agent.Clientset, agentSvc, incidentID)
 
 	if err != nil {
 		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
 		return
 	}
 
-	c.WriteResult(w, r, kubeEvent.ToKubeEventType())
+	c.WriteResult(w, r, incident)
 }

+ 64 - 0
api/server/handlers/cluster/get_k8s_events.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 GetKubernetesEventsHandler struct {
+	handlers.PorterHandlerReadWriter
+	authz.KubernetesAgentGetter
+}
+
+func NewGetKubernetesEventsHandler(
+	config *config.Config,
+	decoderValidator shared.RequestDecoderValidator,
+	writer shared.ResultWriter,
+) *GetKubernetesEventsHandler {
+	return &GetKubernetesEventsHandler{
+		PorterHandlerReadWriter: handlers.NewDefaultPorterHandler(config, decoderValidator, writer),
+		KubernetesAgentGetter:   authz.NewOutOfClusterAgentGetter(config),
+	}
+}
+
+func (c *GetKubernetesEventsHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	cluster, _ := r.Context().Value(types.ClusterScope).(*models.Cluster)
+
+	request := &types.GetKubernetesEventRequest{}
+
+	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.GetHistoricalKubernetesEvents(agent.Clientset, agentSvc, request)
+
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	c.WriteResult(w, r, logs)
+}

+ 7 - 7
api/server/handlers/cluster/get_incident_event_logs.go → api/server/handlers/cluster/get_logs.go

@@ -13,26 +13,26 @@ import (
 	"github.com/porter-dev/porter/internal/models"
 )
 
-type GetIncidentEventLogsHandler struct {
+type GetLogsHandler struct {
 	handlers.PorterHandlerReadWriter
 	authz.KubernetesAgentGetter
 }
 
-func NewGetIncidentEventLogsHandler(
+func NewGetLogsHandler(
 	config *config.Config,
 	decoderValidator shared.RequestDecoderValidator,
 	writer shared.ResultWriter,
-) *GetIncidentEventLogsHandler {
-	return &GetIncidentEventLogsHandler{
+) *GetLogsHandler {
+	return &GetLogsHandler{
 		PorterHandlerReadWriter: handlers.NewDefaultPorterHandler(config, decoderValidator, writer),
 		KubernetesAgentGetter:   authz.NewOutOfClusterAgentGetter(config),
 	}
 }
 
-func (c *GetIncidentEventLogsHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+func (c *GetLogsHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
 	cluster, _ := r.Context().Value(types.ClusterScope).(*models.Cluster)
 
-	request := &types.GetIncidentEventLogsRequest{}
+	request := &types.GetLogRequest{}
 
 	if ok := c.DecodeAndValidate(w, r, request); !ok {
 		return
@@ -53,7 +53,7 @@ func (c *GetIncidentEventLogsHandler) ServeHTTP(w http.ResponseWriter, r *http.R
 		return
 	}
 
-	logs, err := porter_agent.GetLogs(agent.Clientset, agentSvc, request.LogID)
+	logs, err := porter_agent.GetHistoricalLogs(agent.Clientset, agentSvc, request)
 
 	if err != nil {
 		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))

+ 21 - 17
api/server/handlers/kube_events/list.go → api/server/handlers/cluster/get_logs_pod_values.go

@@ -1,4 +1,4 @@
-package kube_events
+package cluster
 
 import (
 	"net/http"
@@ -9,52 +9,56 @@ import (
 	"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 ListKubeEventsHandler struct {
+type GetLogPodValuesHandler struct {
 	handlers.PorterHandlerReadWriter
 	authz.KubernetesAgentGetter
 }
 
-func NewListKubeEventsHandler(
+func NewGetLogPodValuesHandler(
 	config *config.Config,
 	decoderValidator shared.RequestDecoderValidator,
 	writer shared.ResultWriter,
-) *ListKubeEventsHandler {
-	return &ListKubeEventsHandler{
+) *GetLogPodValuesHandler {
+	return &GetLogPodValuesHandler{
 		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)
+func (c *GetLogPodValuesHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
 	cluster, _ := r.Context().Value(types.ClusterScope).(*models.Cluster)
 
-	request := &types.ListKubeEventRequest{}
+	request := &types.GetPodValuesRequest{}
 
 	if ok := c.DecodeAndValidate(w, r, request); !ok {
 		return
 	}
 
-	kubeEvents, count, err := c.Repo().KubeEvent().ListEventsByProjectID(proj.ID, cluster.ID, request)
+	agent, err := c.GetAgent(r, cluster, "")
 
 	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{},
+	// get agent service
+	agentSvc, err := porter_agent.GetAgentService(agent.Clientset)
+
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
 	}
 
-	for _, kubeEvent := range kubeEvents {
-		resp.KubeEvents = append(resp.KubeEvents, kubeEvent.ToKubeEventType())
+	podVals, err := porter_agent.GetPodValues(agent.Clientset, agentSvc, request)
+
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
 	}
 
-	c.WriteResult(w, r, resp)
+	c.WriteResult(w, r, podVals)
 }

+ 64 - 0
api/server/handlers/cluster/get_logs_revision_values.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 GetLogRevisionValuesHandler struct {
+	handlers.PorterHandlerReadWriter
+	authz.KubernetesAgentGetter
+}
+
+func NewGetLogRevisionValuesHandler(
+	config *config.Config,
+	decoderValidator shared.RequestDecoderValidator,
+	writer shared.ResultWriter,
+) *GetLogRevisionValuesHandler {
+	return &GetLogRevisionValuesHandler{
+		PorterHandlerReadWriter: handlers.NewDefaultPorterHandler(config, decoderValidator, writer),
+		KubernetesAgentGetter:   authz.NewOutOfClusterAgentGetter(config),
+	}
+}
+
+func (c *GetLogRevisionValuesHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	cluster, _ := r.Context().Value(types.ClusterScope).(*models.Cluster)
+
+	request := &types.GetRevisionValuesRequest{}
+
+	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
+	}
+
+	revisions, err := porter_agent.GetRevisionValues(agent.Clientset, agentSvc, request)
+
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	c.WriteResult(w, r, revisions)
+}

+ 65 - 0
api/server/handlers/cluster/get_porter_events.go

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

+ 65 - 0
api/server/handlers/cluster/get_porter_job_events.go

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

+ 89 - 8
api/server/handlers/cluster/install_agent.go

@@ -1,6 +1,7 @@
 package cluster
 
 import (
+	"context"
 	"fmt"
 	"net/http"
 
@@ -13,7 +14,15 @@ import (
 	"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/kubernetes"
+	"github.com/porter-dev/porter/internal/kubernetes/nodes"
 	"github.com/porter-dev/porter/internal/models"
+	v1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+)
+
+const (
+	monitoringNodeLabel = "porter.run/workload-kind=monitoring"
+	olderAgentLabel     = "control-plane=controller-manager"
 )
 
 type InstallAgentHandler struct {
@@ -36,6 +45,21 @@ 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)
+
+	k8sAgent, err := c.GetAgent(r, cluster, "porter-agent-system")
+
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	err = checkAndDeleteOlderAgent(k8sAgent)
+
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
 	helmAgent, err := c.GetHelmAgent(r, cluster, "porter-agent-system")
 
 	if err != nil {
@@ -73,18 +97,34 @@ func (c *InstallAgentHandler) ServeHTTP(w http.ResponseWriter, r *http.Request)
 		return
 	}
 
+	lokiValues := make(map[string]interface{})
+
+	// case on whether a node with porter.run/workload-kind=monitoring exists. If it does, we place loki in that node group.
+	if nodes, err := nodes.ListNodesByLabels(k8sAgent.Clientset, "porter.run/workload-kind=monitoring"); err == nil && len(nodes) >= 1 {
+		lokiValues = map[string]interface{}{
+			"nodeSelector": map[string]interface{}{
+				"porter.run/workload-kind": "monitoring",
+			},
+			"tolerations": []map[string]interface{}{
+				{
+					"key":      "porter.run/workload-kind",
+					"operator": "Equal",
+					"value":    "monitoring",
+					"effect":   "NoSchedule",
+				},
+			},
+		}
+	}
+
 	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),
+			"clusterID":   fmt.Sprintf("%d", cluster.ID),
+			"projectID":   fmt.Sprintf("%d", proj.ID),
 		},
+		"loki": lokiValues,
 	}
 
 	conf := &helm.InstallChartConfig{
@@ -100,8 +140,7 @@ func (c *InstallAgentHandler) ServeHTTP(w http.ResponseWriter, r *http.Request)
 
 	if err != nil {
 		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(
-			fmt.Errorf("error installing a new chart: %s", err.Error()),
-			http.StatusBadRequest,
+			fmt.Errorf("error installing porter-agent: %w", err), http.StatusBadRequest,
 		))
 
 		return
@@ -109,3 +148,45 @@ func (c *InstallAgentHandler) ServeHTTP(w http.ResponseWriter, r *http.Request)
 
 	w.WriteHeader(http.StatusOK)
 }
+
+func checkAndDeleteOlderAgent(k8sAgent *kubernetes.Agent) error {
+	namespaceList, err := k8sAgent.Clientset.CoreV1().Namespaces().List(context.Background(), v1.ListOptions{})
+
+	if err != nil {
+		return fmt.Errorf("error listing namespaces: %w", err)
+	}
+
+	nsExists := false
+
+	for _, namespace := range namespaceList.Items {
+		if namespace.Name == "porter-agent-system" {
+			nsExists = true
+			break
+		}
+	}
+
+	if !nsExists {
+		return nil
+	}
+
+	podList, err := k8sAgent.Clientset.CoreV1().Pods("porter-agent-system").List(context.Background(), v1.ListOptions{
+		LabelSelector: olderAgentLabel,
+	})
+
+	if err != nil {
+		return fmt.Errorf("error listing pods for older porter-agent: %w", err)
+	}
+
+	if len(podList.Items) > 0 {
+		// older porter-agent exists, delete the entire namespace
+		err := k8sAgent.Clientset.CoreV1().Namespaces().Delete(
+			context.Background(), "porter-agent-system", v1.DeleteOptions{},
+		)
+
+		if err != nil {
+			return fmt.Errorf("error deleting older porter-agent's namespace: %w", err)
+		}
+	}
+
+	return nil
+}

+ 64 - 0
api/server/handlers/cluster/list_incident_events.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 ListIncidentEventsHandler struct {
+	handlers.PorterHandlerReadWriter
+	authz.KubernetesAgentGetter
+}
+
+func NewListIncidentEventsHandler(
+	config *config.Config,
+	decoderValidator shared.RequestDecoderValidator,
+	writer shared.ResultWriter,
+) *ListIncidentEventsHandler {
+	return &ListIncidentEventsHandler{
+		PorterHandlerReadWriter: handlers.NewDefaultPorterHandler(config, decoderValidator, writer),
+		KubernetesAgentGetter:   authz.NewOutOfClusterAgentGetter(config),
+	}
+}
+
+func (c *ListIncidentEventsHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	cluster, _ := r.Context().Value(types.ClusterScope).(*models.Cluster)
+
+	request := &types.ListIncidentEventsRequest{}
+
+	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
+	}
+
+	events, err := porter_agent.ListIncidentEvents(agent.Clientset, agentSvc, request)
+
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	c.WriteResult(w, r, events)
+}

+ 7 - 37
api/server/handlers/cluster/get_incidents.go → api/server/handlers/cluster/list_incidents.go

@@ -13,35 +13,31 @@ import (
 	"github.com/porter-dev/porter/internal/models"
 )
 
-type GetIncidentsHandler struct {
+type ListIncidentsHandler struct {
 	handlers.PorterHandlerReadWriter
 	authz.KubernetesAgentGetter
 }
 
-func NewGetIncidentsHandler(
+func NewListIncidentsHandler(
 	config *config.Config,
 	decoderValidator shared.RequestDecoderValidator,
 	writer shared.ResultWriter,
-) *GetIncidentsHandler {
-	return &GetIncidentsHandler{
+) *ListIncidentsHandler {
+	return &ListIncidentsHandler{
 		PorterHandlerReadWriter: handlers.NewDefaultPorterHandler(config, decoderValidator, writer),
 		KubernetesAgentGetter:   authz.NewOutOfClusterAgentGetter(config),
 	}
 }
 
-func (c *GetIncidentsHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+func (c *ListIncidentsHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
 	cluster, _ := r.Context().Value(types.ClusterScope).(*models.Cluster)
 
-	request := &types.GetIncidentsRequest{}
+	request := &types.ListIncidentsRequest{}
 
 	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 {
@@ -57,33 +53,7 @@ func (c *GetIncidentsHandler) ServeHTTP(w http.ResponseWriter, r *http.Request)
 		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)
+	incidents, err := porter_agent.ListIncidents(agent.Clientset, agentSvc, request)
 
 	if err != nil {
 		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))

+ 80 - 20
api/server/handlers/cluster/notify_new_incident.go

@@ -1,6 +1,7 @@
 package cluster
 
 import (
+	"errors"
 	"fmt"
 	"net/http"
 	"strings"
@@ -11,9 +12,12 @@ import (
 	"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"
+	"github.com/porter-dev/porter/internal/notifier"
+	"github.com/porter-dev/porter/internal/notifier/sendgrid"
+	"github.com/porter-dev/porter/internal/notifier/slack"
+	"github.com/porter-dev/porter/internal/repository"
+	"gorm.io/gorm"
 )
 
 type NotifyNewIncidentHandler struct {
@@ -35,23 +39,17 @@ func NewNotifyNewIncidentHandler(
 func (c *NotifyNewIncidentHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
 	cluster, _ := r.Context().Value(types.ClusterScope).(*models.Cluster)
 
-	request := &porter_agent.Incident{}
+	request := &types.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 {
+	rel, err := c.Repo().Release().ReadRelease(cluster.ID, request.ReleaseName, request.ReleaseNamespace)
+
+	if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
 		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
 		return
 	}
@@ -69,21 +67,83 @@ func (c *NotifyNewIncidentHandler) ServeHTTP(w http.ResponseWriter, r *http.Requ
 		notifConf = conf.ToNotificationConfigType()
 	}
 
-	notifier := slack.NewIncidentsNotifier(notifConf, slackInts...)
+	users, err := getUsersByProjectID(c.Repo(), cluster.ProjectID)
+
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	notifiers := make([]notifier.IncidentNotifier, 0)
+
+	if c.Config().SlackConf != nil {
+		notifiers = append(notifiers, slack.NewIncidentNotifier(slackInts...))
+	}
+
+	if sc := c.Config().ServerConf; sc.SendgridAPIKey != "" && sc.SendgridSenderEmail != "" && sc.SendgridIncidentAlertTemplateID != "" {
+		notifiers = append(notifiers, sendgrid.NewIncidentNotifier(&sendgrid.IncidentNotifierOpts{
+			SharedOpts: &sendgrid.SharedOpts{
+				APIKey:      c.Config().ServerConf.SendgridAPIKey,
+				SenderEmail: c.Config().ServerConf.SendgridSenderEmail,
+			},
+			IncidentAlertTemplateID: sc.SendgridIncidentAlertTemplateID,
+			Users:                   users,
+		}))
+	}
+
+	multi := notifier.NewMultiIncidentNotifier(
+		notifConf,
+		notifiers...,
+	)
 
 	if !cluster.NotificationsDisabled {
-		err := notifier.NotifyNew(
-			request, fmt.Sprintf(
-				"%s/cluster-dashboard/incidents/%s?namespace=%s",
-				c.Config().ServerConf.ServerURL,
-				request.ID,
-				segments[2],
-			),
+		url := fmt.Sprintf(
+			"%s/applications/%s/%s/%s?project_id=%d",
+			c.Config().ServerConf.ServerURL,
+			cluster.Name,
+			request.ReleaseNamespace,
+			request.ReleaseName,
+			cluster.ProjectID,
 		)
 
+		if strings.ToLower(string(request.InvolvedObjectKind)) == "job" {
+			url = fmt.Sprintf(
+				"%s/jobs/%s/%s/%s?project_id=%d&job=%s",
+				c.Config().ServerConf.ServerURL,
+				cluster.Name,
+				request.ReleaseNamespace,
+				request.ReleaseName,
+				cluster.ProjectID,
+				request.InvolvedObjectName,
+			)
+		}
+
+		fmt.Println("NOTIFYING NEW:", request.ReleaseName, request.InvolvedObjectKind, request.InvolvedObjectName)
+
+		err := multi.NotifyNew(request, url)
+
 		if err != nil {
 			c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
 			return
 		}
 	}
 }
+
+func getUsersByProjectID(repo repository.Repository, projectID uint) ([]*models.User, error) {
+	roles, err := repo.Project().ListProjectRoles(projectID)
+
+	if err != nil {
+		return nil, err
+	}
+
+	roleMap := make(map[uint]*models.Role)
+	idArr := make([]uint, 0)
+
+	for _, role := range roles {
+		roleCp := role
+		roleMap[role.UserID] = &roleCp
+		idArr = append(idArr, role.UserID)
+	}
+
+	return repo.User().ListUsersByIDs(idArr)
+}

+ 58 - 20
api/server/handlers/cluster/notify_resolved_incident.go

@@ -1,6 +1,7 @@
 package cluster
 
 import (
+	"errors"
 	"fmt"
 	"net/http"
 	"strings"
@@ -11,9 +12,11 @@ import (
 	"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"
+	"github.com/porter-dev/porter/internal/notifier"
+	"github.com/porter-dev/porter/internal/notifier/sendgrid"
+	"github.com/porter-dev/porter/internal/notifier/slack"
+	"gorm.io/gorm"
 )
 
 type NotifyResolvedIncidentHandler struct {
@@ -35,23 +38,17 @@ func NewNotifyResolvedIncidentHandler(
 func (c *NotifyResolvedIncidentHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
 	cluster, _ := r.Context().Value(types.ClusterScope).(*models.Cluster)
 
-	request := &porter_agent.Incident{}
+	request := &types.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 {
+	rel, err := c.Repo().Release().ReadRelease(cluster.ID, request.ReleaseName, request.ReleaseNamespace)
+
+	if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
 		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
 		return
 	}
@@ -69,18 +66,59 @@ func (c *NotifyResolvedIncidentHandler) ServeHTTP(w http.ResponseWriter, r *http
 		notifConf = conf.ToNotificationConfigType()
 	}
 
-	notifier := slack.NewIncidentsNotifier(notifConf, slackInts...)
+	notifiers := make([]notifier.IncidentNotifier, 0)
+
+	if c.Config().SlackConf != nil {
+		notifiers = append(notifiers, slack.NewIncidentNotifier(slackInts...))
+	}
+
+	if sc := c.Config().ServerConf; sc.SendgridAPIKey != "" && sc.SendgridSenderEmail != "" && sc.SendgridIncidentAlertTemplateID != "" {
+		users, err := getUsersByProjectID(c.Repo(), cluster.ProjectID)
+
+		if err != nil {
+			c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+			return
+		}
+
+		notifiers = append(notifiers, sendgrid.NewIncidentNotifier(&sendgrid.IncidentNotifierOpts{
+			SharedOpts: &sendgrid.SharedOpts{
+				APIKey:      c.Config().ServerConf.SendgridAPIKey,
+				SenderEmail: c.Config().ServerConf.SendgridSenderEmail,
+			},
+			IncidentResolvedTemplateID: sc.SendgridIncidentResolvedTemplateID,
+			Users:                      users,
+		}))
+	}
+
+	multi := notifier.NewMultiIncidentNotifier(
+		notifConf,
+		notifiers...,
+	)
 
 	if !cluster.NotificationsDisabled {
-		err := notifier.NotifyResolved(
-			request, fmt.Sprintf(
-				"%s/cluster-dashboard/incidents/%s?namespace=%s",
-				c.Config().ServerConf.ServerURL,
-				request.ID,
-				segments[2],
-			),
+		url := fmt.Sprintf(
+			"%s/applications/%s/%s/%s?project_id=%d",
+			c.Config().ServerConf.ServerURL,
+			cluster.Name,
+			request.ReleaseNamespace,
+			request.ReleaseName,
+			cluster.ProjectID,
 		)
 
+		if strings.ToLower(string(request.InvolvedObjectKind)) == "job" {
+			url = fmt.Sprintf(
+				"%s/jobs/%s/%s/%s?project_id=%d&job=%s",
+				c.Config().ServerConf.ServerURL,
+				cluster.Name,
+				request.ReleaseNamespace,
+				request.ReleaseName,
+				cluster.ProjectID,
+				request.InvolvedObjectName,
+			)
+		}
+
+		err := multi.NotifyResolved(request, url)
+
 		if err != nil {
 			c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
 			return

+ 7 - 1
api/server/handlers/cluster/update.go

@@ -61,7 +61,13 @@ func (c *ClusterUpdateHandler) ServeHTTP(w http.ResponseWriter, r *http.Request)
 		cluster.AWSClusterID = request.AWSClusterID
 	}
 
-	cluster.Name = request.Name
+	if request.AgentIntegrationEnabled != nil {
+		cluster.AgentIntegrationEnabled = *request.AgentIntegrationEnabled
+	}
+
+	if request.Name != "" && cluster.Name != request.Name {
+		cluster.Name = request.Name
+	}
 
 	cluster, err := c.Repo().Cluster().UpdateCluster(cluster)
 

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

@@ -1,409 +0,0 @@
-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/helm/grapher"
-	"github.com/porter-dev/porter/internal/integrations/slack"
-	"github.com/porter-dev/porter/internal/kubernetes"
-	"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" &&
-		request.Message != "Unable to determine the root cause of the error" {
-		agent, err := c.GetAgent(r, cluster, request.Namespace)
-
-		if err != nil {
-			c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
-			return
-		}
-
-		err = notifyPodCrashing(c.Config(), agent, proj, cluster, request)
-
-		if err != nil {
-			c.HandleAPIErrorNoWrite(w, r, apierrors.NewErrInternal(err))
-		}
-	}
-}
-
-func mapKubeEventToMessage(event *types.CreateKubeEventRequest) string {
-	if strings.HasSuffix(event.Reason, "RunContainerError") {
-		if strings.Contains(event.Message, "exec:") {
-			return fmt.Sprintf("Application launch error: %s\n",
-				strings.Split(strings.SplitAfter(event.Message, "exec: ")[1], ": unknown")[0])
-		}
-	} else if strings.HasSuffix(event.Reason, "ImagePullBackOff") {
-		return "Deployment error: The application image could not be pulled from the registry"
-	}
-
-	return event.Message
-}
-
-func notifyPodCrashing(
-	config *config.Config,
-	agent *kubernetes.Agent,
-	project *models.Project,
-	cluster *models.Cluster,
-	event *types.CreateKubeEventRequest,
-) error {
-	// if cluster has notifications turned off, don't alert
-	if cluster.NotificationsDisabled {
-		return nil
-	}
-
-	// attempt to get a matching Porter release to get the notification configuration
-	var conf *models.NotificationConfig
-	var notifConfig *types.NotificationConfig
-	var notifyOpts *slack.NotifyOpts
-	var matchedRel *models.Release
-	var err error
-
-	if isJob := strings.ToLower(event.OwnerType) == "job"; isJob {
-		// check that the job alert is valid and get proper message
-		jobOwner, jobMsg, jobName, shouldAlert, err := getJobAlert(agent, event.Name, event.Namespace)
-
-		if err != nil {
-			return err
-		} else if !shouldAlert {
-			return nil
-		}
-
-		// look for a matching job notification config
-		jobNC, err := config.Repo.JobNotificationConfig().ReadNotificationConfig(project.ID, cluster.ID, jobName, event.Namespace)
-
-		if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
-			return err
-		}
-
-		if err != nil && errors.Is(err, gorm.ErrRecordNotFound) {
-			// if the job notification config does not exist, create it
-			jobNC = &models.JobNotificationConfig{
-				Name:             jobName,
-				Namespace:        event.Namespace,
-				ProjectID:        project.ID,
-				ClusterID:        cluster.ID,
-				LastNotifiedTime: time.Now(),
-			}
-
-			jobNC, err = config.Repo.JobNotificationConfig().CreateNotificationConfig(jobNC)
-
-			if err != nil {
-				return err
-			}
-		} else if err != nil {
-			return err
-		} else if err == nil && jobNC != nil {
-			// If the job notification config does exist, check if the job notification config states that
-			// a notification should happen. If so, notify.
-			if !jobNC.ShouldNotify() {
-				return nil
-			}
-		}
-
-		notifyOpts = &slack.NotifyOpts{
-			ProjectID:   cluster.ProjectID,
-			ClusterID:   cluster.ID,
-			ClusterName: cluster.Name,
-			Name:        jobOwner,
-			Namespace:   event.Namespace,
-			Info:        fmt.Sprintf("%s", jobMsg),
-			Timestamp:   &event.Timestamp,
-			URL: fmt.Sprintf(
-				"%s/jobs/%s/%s/%s?project_id=%d&job=%s",
-				config.ServerConf.ServerURL,
-				cluster.Name,
-				event.Namespace,
-				jobOwner,
-				cluster.ProjectID,
-				jobName,
-			),
-		}
-	} else {
-		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()
-		}
-
-		notifyOpts = &slack.NotifyOpts{
-			ProjectID:   cluster.ProjectID,
-			ClusterID:   cluster.ID,
-			ClusterName: cluster.Name,
-			Name:        event.OwnerName,
-			Namespace:   event.Namespace,
-			Info:        mapKubeEventToMessage(event),
-			URL: fmt.Sprintf(
-				"%s/applications/%s/%s/%s?project_id=%d",
-				config.ServerConf.ServerURL,
-				url.PathEscape(cluster.Name),
-				matchedRel.Namespace,
-				matchedRel.Name,
-				cluster.ProjectID,
-			),
-		}
-	}
-
-	slackInts, _ := config.Repo.SlackIntegration().ListSlackIntegrationsByProjectID(project.ID)
-
-	notifier := slack.NewSlackNotifier(notifConfig, slackInts...)
-	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
-}
-
-func getJobAlert(agent *kubernetes.Agent, name, namespace string) (
-	ownerName string,
-	msg string,
-	jobName string,
-	shouldAlert bool,
-	err error,
-) {
-	ownerName = ""
-
-	pod, err := agent.GetPodByName(name, namespace)
-
-	// if the pod is not found, we should not alert for this pod
-	if err != nil && errors.Is(err, kubernetes.IsNotFoundError) {
-		return "", "", "", false, nil
-	} else if err != nil {
-		return "", "", "", false, err
-	}
-
-	ownerJobName := ""
-
-	// get the owner name for the pod by looking at the owner reference
-	if ownerRefArr := pod.ObjectMeta.OwnerReferences; len(ownerRefArr) > 0 {
-		for _, ownerRef := range ownerRefArr {
-			if strings.ToLower(ownerRef.Kind) == "job" {
-				ownerJobName = ownerRef.Name
-			}
-		}
-	}
-
-	if ownerJobName == "" {
-		return "", "", "", false, nil
-	}
-
-	// lookup the job in the cluster
-	job, err := agent.GetJob(grapher.Object{
-		Kind:      "Job",
-		Name:      ownerJobName,
-		Namespace: namespace,
-	})
-
-	if err != nil {
-		return "", "", "", false, nil
-	}
-
-	if jobReleaseLabel, exists := job.ObjectMeta.Labels["meta.helm.sh/release-name"]; exists {
-		ownerName = jobReleaseLabel
-	}
-
-	// if we don't have an owner name, don't alert -- the link will be broken
-	if ownerName == "" {
-		return "", "", "", false, nil
-	}
-
-	// only alert for jobs that are newer than 24 hours
-	if podTime := pod.Status.StartTime; podTime != nil && podTime.After(time.Now().Add(-24*time.Hour)) {
-		// find container statuses relating to the actual job container. We don't alert on sidecar containers
-		for _, containerStatus := range pod.Status.ContainerStatuses {
-			if containerStatus.Name != "sidecar" && containerStatus.Name != "cloud-sql-proxy" {
-				state := containerStatus.State
-				if state.Terminated != nil && state.Terminated.ExitCode != 0 {
-					// before alerting, we check pod events to make sure the pod was not moved due to normal behavior such as scale down
-					events, err := agent.ListEvents(name, namespace)
-
-					if err == nil && len(events.Items) > 0 {
-						for _, event := range events.Items {
-							// if event is ScaleDown, don't alert
-							if event.Reason == "ScaleDown" && strings.Contains(event.Message, "deleting pod for node scale down") {
-								return ownerName, "", ownerJobName, false, nil
-							}
-						}
-					}
-
-					// next, if the exit code is 255, we check that the job doesn't have a different associated pod.
-					// exit code 255 can mean this pod was moved to a different node due to node eviction, scaledown,
-					// unhealthy node, etc
-					if state.Terminated.ExitCode == 255 {
-						jobPods, err := agent.GetJobPods(namespace, ownerJobName)
-
-						if err == nil && len(jobPods) > 0 {
-							for _, jobPod := range jobPods {
-								if jobPod.ObjectMeta.Name != name {
-									return ownerName, "", ownerJobName, false, nil
-								}
-							}
-						}
-					}
-
-					msg := fmt.Sprintf("Job terminated with non-zero exit code: exit code %d.", state.Terminated.ExitCode)
-
-					if state.Terminated.Message != "" {
-						msg += fmt.Sprintf(" Error: %s", state.Terminated.Message)
-					}
-
-					return ownerName, msg, ownerJobName, true, nil
-				}
-			}
-		}
-	}
-
-	return "", "", "", false, nil
-}

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

@@ -1,96 +0,0 @@
-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,
-	})
-}

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

@@ -1,97 +0,0 @@
-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,
-	})
-}

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

@@ -0,0 +1,73 @@
+package namespace
+
+import (
+	"fmt"
+	"net/http"
+	"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/server/shared/websocket"
+	"github.com/porter-dev/porter/api/types"
+	"github.com/porter-dev/porter/internal/models"
+)
+
+type StreamPodLogsLokiHandler struct {
+	handlers.PorterHandlerReadWriter
+	authz.KubernetesAgentGetter
+}
+
+func NewStreamPodLogsLokiHandler(
+	config *config.Config,
+	decoderValidator shared.RequestDecoderValidator,
+	writer shared.ResultWriter,
+) *StreamPodLogsLokiHandler {
+	return &StreamPodLogsLokiHandler{
+		PorterHandlerReadWriter: handlers.NewDefaultPorterHandler(config, decoderValidator, writer),
+		KubernetesAgentGetter:   authz.NewOutOfClusterAgentGetter(config),
+	}
+}
+
+func (c *StreamPodLogsLokiHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	request := &types.GetLogRequest{}
+
+	if ok := c.DecodeAndValidate(w, r, request); !ok {
+		return
+	}
+
+	safeRW := r.Context().Value(types.RequestCtxWebsocketKey).(*websocket.WebsocketSafeReadWriter)
+
+	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
+	}
+
+	if request.StartRange == nil {
+		dayAgo := time.Now().Add(-24 * time.Hour)
+		request.StartRange = &dayAgo
+	}
+
+	startTime, err := request.StartRange.MarshalText()
+
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	err = agent.StreamPorterAgentLokiLog([]string{
+		fmt.Sprintf("pod=%s", request.PodSelector),
+		fmt.Sprintf("namespace=%s", request.Namespace),
+	}, string(startTime), request.SearchParam, 0, safeRW)
+
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+}

+ 8 - 7
api/server/handlers/release/upgrade.go

@@ -14,8 +14,9 @@ import (
 	"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/integrations/slack"
 	"github.com/porter-dev/porter/internal/models"
+	"github.com/porter-dev/porter/internal/notifier"
+	"github.com/porter-dev/porter/internal/notifier/slack"
 	"github.com/porter-dev/porter/internal/stacks"
 	"helm.sh/helm/v3/pkg/release"
 )
@@ -182,9 +183,9 @@ func (c *UpgradeReleaseHandler) ServeHTTP(w http.ResponseWriter, r *http.Request
 		notifConf = conf.ToNotificationConfigType()
 	}
 
-	notifier := slack.NewSlackNotifier(notifConf, slackInts...)
+	deplNotifier := slack.NewDeploymentNotifier(notifConf, slackInts...)
 
-	notifyOpts := &slack.NotifyOpts{
+	notifyOpts := &notifier.NotifyOpts{
 		ProjectID:   cluster.ProjectID,
 		ClusterID:   cluster.ID,
 		ClusterName: cluster.Name,
@@ -201,11 +202,11 @@ func (c *UpgradeReleaseHandler) ServeHTTP(w http.ResponseWriter, r *http.Request
 	}
 
 	if upgradeErr != nil {
-		notifyOpts.Status = slack.StatusHelmFailed
+		notifyOpts.Status = notifier.StatusHelmFailed
 		notifyOpts.Info = upgradeErr.Error()
 
 		if !cluster.NotificationsDisabled {
-			notifier.Notify(notifyOpts)
+			deplNotifier.Notify(notifyOpts)
 		}
 
 		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(
@@ -217,11 +218,11 @@ func (c *UpgradeReleaseHandler) ServeHTTP(w http.ResponseWriter, r *http.Request
 	}
 
 	if helmRelease.Chart != nil && helmRelease.Chart.Metadata.Name != "job" {
-		notifyOpts.Status = slack.StatusHelmDeployed
+		notifyOpts.Status = notifier.StatusHelmDeployed
 		notifyOpts.Version = helmRelease.Version
 
 		if !cluster.NotificationsDisabled {
-			notifier.Notify(notifyOpts)
+			deplNotifier.Notify(notifyOpts)
 		}
 	}
 

+ 8 - 7
api/server/handlers/release/upgrade_webhook.go

@@ -14,7 +14,8 @@ import (
 	"github.com/porter-dev/porter/api/types"
 	"github.com/porter-dev/porter/internal/analytics"
 	"github.com/porter-dev/porter/internal/helm"
-	"github.com/porter-dev/porter/internal/integrations/slack"
+	"github.com/porter-dev/porter/internal/notifier"
+	"github.com/porter-dev/porter/internal/notifier/slack"
 	"gorm.io/gorm"
 )
 
@@ -155,9 +156,9 @@ func (c *WebhookHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
 		notifConf = conf.ToNotificationConfigType()
 	}
 
-	notifier := slack.NewSlackNotifier(notifConf, slackInts...)
+	deplNotifier := slack.NewDeploymentNotifier(notifConf, slackInts...)
 
-	notifyOpts := &slack.NotifyOpts{
+	notifyOpts := &notifier.NotifyOpts{
 		ProjectID:   release.ProjectID,
 		ClusterID:   cluster.ID,
 		ClusterName: cluster.Name,
@@ -176,11 +177,11 @@ func (c *WebhookHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
 	rel, err = helmAgent.UpgradeReleaseByValues(conf, c.Config().DOConf)
 
 	if err != nil {
-		notifyOpts.Status = slack.StatusHelmFailed
+		notifyOpts.Status = notifier.StatusHelmFailed
 		notifyOpts.Info = err.Error()
 
 		if !cluster.NotificationsDisabled {
-			notifier.Notify(notifyOpts)
+			deplNotifier.Notify(notifyOpts)
 		}
 
 		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(
@@ -192,11 +193,11 @@ func (c *WebhookHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
 	}
 
 	if rel.Chart != nil && rel.Chart.Metadata.Name != "job" {
-		notifyOpts.Status = slack.StatusHelmDeployed
+		notifyOpts.Status = notifier.StatusHelmDeployed
 		notifyOpts.Version = rel.Version
 
 		if !cluster.NotificationsDisabled {
-			notifier.Notify(notifyOpts)
+			deplNotifier.Notify(notifyOpts)
 		}
 	}
 

+ 8 - 7
api/server/handlers/v1/release/upgrade.go

@@ -15,8 +15,9 @@ import (
 	"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/integrations/slack"
 	"github.com/porter-dev/porter/internal/models"
+	"github.com/porter-dev/porter/internal/notifier"
+	"github.com/porter-dev/porter/internal/notifier/slack"
 	"helm.sh/helm/v3/pkg/release"
 )
 
@@ -166,9 +167,9 @@ func (c *UpgradeReleaseHandler) ServeHTTP(w http.ResponseWriter, r *http.Request
 		notifConf = conf.ToNotificationConfigType()
 	}
 
-	notifier := slack.NewSlackNotifier(notifConf, slackInts...)
+	deplNotifier := slack.NewDeploymentNotifier(notifConf, slackInts...)
 
-	notifyOpts := &slack.NotifyOpts{
+	notifyOpts := &notifier.NotifyOpts{
 		ProjectID:   cluster.ProjectID,
 		ClusterID:   cluster.ID,
 		ClusterName: cluster.Name,
@@ -185,11 +186,11 @@ func (c *UpgradeReleaseHandler) ServeHTTP(w http.ResponseWriter, r *http.Request
 	}
 
 	if upgradeErr != nil {
-		notifyOpts.Status = slack.StatusHelmFailed
+		notifyOpts.Status = notifier.StatusHelmFailed
 		notifyOpts.Info = upgradeErr.Error()
 
 		if !cluster.NotificationsDisabled {
-			notifier.Notify(notifyOpts)
+			deplNotifier.Notify(notifyOpts)
 		}
 
 		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(
@@ -201,11 +202,11 @@ func (c *UpgradeReleaseHandler) ServeHTTP(w http.ResponseWriter, r *http.Request
 	}
 
 	if helmRelease.Chart != nil && helmRelease.Chart.Metadata.Name != "job" {
-		notifyOpts.Status = slack.StatusHelmDeployed
+		notifyOpts.Status = notifier.StatusHelmDeployed
 		notifyOpts.Version = helmRelease.Version
 
 		if !cluster.NotificationsDisabled {
-			notifier.Notify(notifyOpts)
+			deplNotifier.Notify(notifyOpts)
 		}
 	}
 

+ 164 - 82
api/server/router/cluster.go

@@ -7,7 +7,6 @@ import (
 	"github.com/porter-dev/porter/api/server/handlers/cluster"
 	"github.com/porter-dev/porter/api/server/handlers/database"
 	"github.com/porter-dev/porter/api/server/handlers/environment"
-	"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/server/shared/router"
@@ -860,6 +859,31 @@ func getClusterRoutes(
 		Router:   r,
 	})
 
+	// GET /api/projects/{project_id}/clusters/{cluster_id}/agent/status -> cluster.NewGetAgentStatusHandler
+	getAgentStatusEndpoint := factory.NewAPIEndpoint(
+		&types.APIRequestMetadata{
+			Verb:   types.APIVerbGet,
+			Method: types.HTTPVerbGet,
+			Path: &types.Path{
+				Parent:       basePath,
+				RelativePath: relPath + "/agent/status",
+			},
+			Scopes: []types.PermissionScope{
+				types.UserScope,
+				types.ProjectScope,
+				types.ClusterScope,
+			},
+		},
+	)
+
+	getAgentStatusHandler := cluster.NewGetAgentStatusHandler(config, factory.GetResultWriter())
+
+	routes = append(routes, &router.Route{
+		Endpoint: getAgentStatusEndpoint,
+		Handler:  getAgentStatusHandler,
+		Router:   r,
+	})
+
 	// POST /api/projects/{project_id}/clusters/{cluster_id}/agent/upgrade -> cluster.NewInstallAgentHandler
 	upgradeAgentEndpoint := factory.NewAPIEndpoint(
 		&types.APIRequestMetadata{
@@ -889,14 +913,14 @@ func getClusterRoutes(
 		Router:   r,
 	})
 
-	// GET /api/projects/{project_id}/clusters/{cluster_id}/kube_events -> kube_events.NewGetKubeEventHandler
-	listKubeEventsEndpoint := factory.NewAPIEndpoint(
+	// GET /api/projects/{project_id}/clusters/{cluster_id}/prometheus/ingresses -> cluster.NewListNGINXIngressesHandler
+	listNGINXIngressesEndpoint := factory.NewAPIEndpoint(
 		&types.APIRequestMetadata{
 			Verb:   types.APIVerbGet,
 			Method: types.HTTPVerbGet,
 			Path: &types.Path{
 				Parent:       basePath,
-				RelativePath: relPath + "/kube_events",
+				RelativePath: relPath + "/prometheus/ingresses",
 			},
 			Scopes: []types.PermissionScope{
 				types.UserScope,
@@ -906,55 +930,118 @@ func getClusterRoutes(
 		},
 	)
 
-	listKubeEventsHandler := kube_events.NewListKubeEventsHandler(
+	listNGINXIngressesHandler := cluster.NewListNGINXIngressesHandler(
+		config,
+		factory.GetResultWriter(),
+	)
+
+	routes = append(routes, &router.Route{
+		Endpoint: listNGINXIngressesEndpoint,
+		Handler:  listNGINXIngressesHandler,
+		Router:   r,
+	})
+
+	// GET /api/projects/{project_id}/clusters/{cluster_id}/metrics -> cluster.NewGetPodMetricsHandler
+	getPodMetricsEndpoint := factory.NewAPIEndpoint(
+		&types.APIRequestMetadata{
+			Verb:   types.APIVerbGet,
+			Method: types.HTTPVerbGet,
+			Path: &types.Path{
+				Parent:       basePath,
+				RelativePath: relPath + "/metrics",
+			},
+			Scopes: []types.PermissionScope{
+				types.UserScope,
+				types.ProjectScope,
+				types.ClusterScope,
+			},
+		},
+	)
+
+	getPodMetricsHandler := cluster.NewGetPodMetricsHandler(
 		config,
 		factory.GetDecoderValidator(),
 		factory.GetResultWriter(),
 	)
 
 	routes = append(routes, &router.Route{
-		Endpoint: listKubeEventsEndpoint,
-		Handler:  listKubeEventsHandler,
+		Endpoint: getPodMetricsEndpoint,
+		Handler:  getPodMetricsHandler,
 		Router:   r,
 	})
 
-	// GET /api/projects/{project_id}/clusters/{cluster_id}/kube_events -> kube_events.NewGetKubeEventHandler
-	getKubeEventEndpoint := factory.NewAPIEndpoint(
+	// GET /api/projects/{project_id}/clusters/{cluster_id}/helm_release -> cluster.NewStreamHelmReleaseHandler
+	streamHelmReleaseEndpoint := 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),
+				RelativePath: relPath + "/helm_release",
 			},
 			Scopes: []types.PermissionScope{
 				types.UserScope,
 				types.ProjectScope,
 				types.ClusterScope,
 			},
+			IsWebsocket: true,
 		},
 	)
 
-	getKubeEventHandler := kube_events.NewGetKubeEventHandler(
+	streamHelmReleaseHandler := cluster.NewStreamHelmReleaseHandler(
 		config,
 		factory.GetDecoderValidator(),
 		factory.GetResultWriter(),
 	)
 
 	routes = append(routes, &router.Route{
-		Endpoint: getKubeEventEndpoint,
-		Handler:  getKubeEventHandler,
+		Endpoint: streamHelmReleaseEndpoint,
+		Handler:  streamHelmReleaseHandler,
 		Router:   r,
 	})
 
-	// GET /api/projects/{project_id}/clusters/{cluster_id}/kube_events/{kube_event_id}/logs -> kube_events.NewGetKubeEventLogsHandler
-	getKubeEventLogsEndpoint := factory.NewAPIEndpoint(
+	// GET /api/projects/{project_id}/clusters/{cluster_id}/{kind}/status -> cluster.NewStreamStatusHandler
+	streamStatusEndpoint := factory.NewAPIEndpoint(
+		&types.APIRequestMetadata{
+			Verb:   types.APIVerbGet,
+			Method: types.HTTPVerbGet,
+			Path: &types.Path{
+				Parent: basePath,
+				RelativePath: fmt.Sprintf(
+					"%s/{%s}/status",
+					relPath,
+					types.URLParamKind,
+				),
+			},
+			Scopes: []types.PermissionScope{
+				types.UserScope,
+				types.ProjectScope,
+				types.ClusterScope,
+			},
+			IsWebsocket: true,
+		},
+	)
+
+	streamStatusHandler := cluster.NewStreamStatusHandler(
+		config,
+		factory.GetDecoderValidator(),
+		factory.GetResultWriter(),
+	)
+
+	routes = append(routes, &router.Route{
+		Endpoint: streamStatusEndpoint,
+		Handler:  streamStatusHandler,
+		Router:   r,
+	})
+
+	// GET /api/projects/{project_id}/clusters/{cluster_id}/pods -> cluster.NewGetPodsHandler
+	getPodsEndpoint := 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),
+				RelativePath: relPath + "/pods",
 			},
 			Scopes: []types.PermissionScope{
 				types.UserScope,
@@ -964,26 +1051,26 @@ func getClusterRoutes(
 		},
 	)
 
-	getKubeEventLogsHandler := kube_events.NewGetKubeEventLogsHandler(
+	getPodsHandler := cluster.NewGetPodsHandler(
 		config,
 		factory.GetDecoderValidator(),
 		factory.GetResultWriter(),
 	)
 
 	routes = append(routes, &router.Route{
-		Endpoint: getKubeEventLogsEndpoint,
-		Handler:  getKubeEventLogsHandler,
+		Endpoint: getPodsEndpoint,
+		Handler:  getPodsHandler,
 		Router:   r,
 	})
 
-	// GET /api/projects/{project_id}/clusters/{cluster_id}/kube_events/{kube_event_id}/log_buckets -> kube_events.NewGetKubeEventLogBucketsHandler
-	getKubeEventLogBucketsEndpoint := factory.NewAPIEndpoint(
+	// GET /api/projects/{project_id}/clusters/{cluster_id}/incidents -> cluster.NewListIncidentsHandler
+	listIncidentsEndpoint := 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),
+				RelativePath: relPath + "/incidents",
 			},
 			Scopes: []types.PermissionScope{
 				types.UserScope,
@@ -993,26 +1080,26 @@ func getClusterRoutes(
 		},
 	)
 
-	getKubeEventLogBucketsHandler := kube_events.NewGetKubeEventLogBucketsHandler(
+	listIncidentsHandler := cluster.NewListIncidentsHandler(
 		config,
 		factory.GetDecoderValidator(),
 		factory.GetResultWriter(),
 	)
 
 	routes = append(routes, &router.Route{
-		Endpoint: getKubeEventLogBucketsEndpoint,
-		Handler:  getKubeEventLogBucketsHandler,
+		Endpoint: listIncidentsEndpoint,
+		Handler:  listIncidentsHandler,
 		Router:   r,
 	})
 
-	// POST /api/projects/{project_id}/clusters/{cluster_id}/kube_events -> kube_events.NewCreateKubeEventHandler
-	createKubeEventsEndpoint := factory.NewAPIEndpoint(
+	// GET /api/projects/{project_id}/clusters/{cluster_id}/incidents/{incident_id} -> cluster.NewGetIncidentHandler
+	getIncidentEndpoint := factory.NewAPIEndpoint(
 		&types.APIRequestMetadata{
-			Verb:   types.APIVerbCreate,
-			Method: types.HTTPVerbPost,
+			Verb:   types.APIVerbGet,
+			Method: types.HTTPVerbGet,
 			Path: &types.Path{
 				Parent:       basePath,
-				RelativePath: relPath + "/kube_events",
+				RelativePath: fmt.Sprintf("%s/incidents/{%s}", relPath, types.URLParamIncidentID),
 			},
 			Scopes: []types.PermissionScope{
 				types.UserScope,
@@ -1022,26 +1109,26 @@ func getClusterRoutes(
 		},
 	)
 
-	createKubeEventsHandler := kube_events.NewCreateKubeEventHandler(
+	getIncidentHandler := cluster.NewGetIncidentHandler(
 		config,
 		factory.GetDecoderValidator(),
 		factory.GetResultWriter(),
 	)
 
 	routes = append(routes, &router.Route{
-		Endpoint: createKubeEventsEndpoint,
-		Handler:  createKubeEventsHandler,
+		Endpoint: getIncidentEndpoint,
+		Handler:  getIncidentHandler,
 		Router:   r,
 	})
 
-	// GET /api/projects/{project_id}/clusters/{cluster_id}/prometheus/ingresses -> cluster.NewListNGINXIngressesHandler
-	listNGINXIngressesEndpoint := factory.NewAPIEndpoint(
+	// GET /api/projects/{project_id}/clusters/{cluster_id}/incidents/events -> cluster.NewListIncidentEventsHandler
+	listIncidentEventsEndpoint := factory.NewAPIEndpoint(
 		&types.APIRequestMetadata{
 			Verb:   types.APIVerbGet,
 			Method: types.HTTPVerbGet,
 			Path: &types.Path{
 				Parent:       basePath,
-				RelativePath: relPath + "/prometheus/ingresses",
+				RelativePath: fmt.Sprintf("%s/incidents/events", relPath),
 			},
 			Scopes: []types.PermissionScope{
 				types.UserScope,
@@ -1051,25 +1138,26 @@ func getClusterRoutes(
 		},
 	)
 
-	listNGINXIngressesHandler := cluster.NewListNGINXIngressesHandler(
+	listIncidentEventsHandler := cluster.NewListIncidentEventsHandler(
 		config,
+		factory.GetDecoderValidator(),
 		factory.GetResultWriter(),
 	)
 
 	routes = append(routes, &router.Route{
-		Endpoint: listNGINXIngressesEndpoint,
-		Handler:  listNGINXIngressesHandler,
+		Endpoint: listIncidentEventsEndpoint,
+		Handler:  listIncidentEventsHandler,
 		Router:   r,
 	})
 
-	// GET /api/projects/{project_id}/clusters/{cluster_id}/metrics -> cluster.NewGetPodMetricsHandler
-	getPodMetricsEndpoint := factory.NewAPIEndpoint(
+	// GET /api/projects/{project_id}/clusters/{cluster_id}/logs -> cluster.NewGetLogsHandler
+	getLogsEndpoint := factory.NewAPIEndpoint(
 		&types.APIRequestMetadata{
 			Verb:   types.APIVerbGet,
 			Method: types.HTTPVerbGet,
 			Path: &types.Path{
 				Parent:       basePath,
-				RelativePath: relPath + "/metrics",
+				RelativePath: fmt.Sprintf("%s/logs", relPath),
 			},
 			Scopes: []types.PermissionScope{
 				types.UserScope,
@@ -1079,90 +1167,84 @@ func getClusterRoutes(
 		},
 	)
 
-	getPodMetricsHandler := cluster.NewGetPodMetricsHandler(
+	getLogsHandler := cluster.NewGetLogsHandler(
 		config,
 		factory.GetDecoderValidator(),
 		factory.GetResultWriter(),
 	)
 
 	routes = append(routes, &router.Route{
-		Endpoint: getPodMetricsEndpoint,
-		Handler:  getPodMetricsHandler,
+		Endpoint: getLogsEndpoint,
+		Handler:  getLogsHandler,
 		Router:   r,
 	})
 
-	// GET /api/projects/{project_id}/clusters/{cluster_id}/helm_release -> cluster.NewStreamHelmReleaseHandler
-	streamHelmReleaseEndpoint := factory.NewAPIEndpoint(
+	// GET /api/projects/{project_id}/clusters/{cluster_id}/logs/pod_values -> cluster.NewGetLogPodValuesHandler
+	getLogPodValuesEndpoint := factory.NewAPIEndpoint(
 		&types.APIRequestMetadata{
 			Verb:   types.APIVerbGet,
 			Method: types.HTTPVerbGet,
 			Path: &types.Path{
 				Parent:       basePath,
-				RelativePath: relPath + "/helm_release",
+				RelativePath: fmt.Sprintf("%s/logs/pod_values", relPath),
 			},
 			Scopes: []types.PermissionScope{
 				types.UserScope,
 				types.ProjectScope,
 				types.ClusterScope,
 			},
-			IsWebsocket: true,
 		},
 	)
 
-	streamHelmReleaseHandler := cluster.NewStreamHelmReleaseHandler(
+	getLogPodValuesHandler := cluster.NewGetLogPodValuesHandler(
 		config,
 		factory.GetDecoderValidator(),
 		factory.GetResultWriter(),
 	)
 
 	routes = append(routes, &router.Route{
-		Endpoint: streamHelmReleaseEndpoint,
-		Handler:  streamHelmReleaseHandler,
+		Endpoint: getLogPodValuesEndpoint,
+		Handler:  getLogPodValuesHandler,
 		Router:   r,
 	})
 
-	// GET /api/projects/{project_id}/clusters/{cluster_id}/{kind}/status -> cluster.NewStreamStatusHandler
-	streamStatusEndpoint := factory.NewAPIEndpoint(
+	// GET /api/projects/{project_id}/clusters/{cluster_id}/logs/revision_values -> cluster.NewGetLogPodValuesHandler
+	getLogRevisionValuesEndpoint := factory.NewAPIEndpoint(
 		&types.APIRequestMetadata{
 			Verb:   types.APIVerbGet,
 			Method: types.HTTPVerbGet,
 			Path: &types.Path{
-				Parent: basePath,
-				RelativePath: fmt.Sprintf(
-					"%s/{%s}/status",
-					relPath,
-					types.URLParamKind,
-				),
+				Parent:       basePath,
+				RelativePath: fmt.Sprintf("%s/logs/revision_values", relPath),
 			},
 			Scopes: []types.PermissionScope{
 				types.UserScope,
 				types.ProjectScope,
 				types.ClusterScope,
 			},
-			IsWebsocket: true,
 		},
 	)
 
-	streamStatusHandler := cluster.NewStreamStatusHandler(
+	getLogRevisionValuesHandler := cluster.NewGetLogRevisionValuesHandler(
 		config,
 		factory.GetDecoderValidator(),
 		factory.GetResultWriter(),
 	)
 
 	routes = append(routes, &router.Route{
-		Endpoint: streamStatusEndpoint,
-		Handler:  streamStatusHandler,
+		Endpoint: getLogRevisionValuesEndpoint,
+		Handler:  getLogRevisionValuesHandler,
 		Router:   r,
 	})
 
-	// GET /api/projects/{project_id}/clusters/{cluster_id}/pods -> cluster.NewGetPodsHandler
-	getPodsEndpoint := factory.NewAPIEndpoint(
+	// GET /api/projects/{project_id}/clusters/{cluster_id}/events -> cluster.NewGetEventsHandler
+	getPorterEventsEndpoint := factory.NewAPIEndpoint(
 		&types.APIRequestMetadata{
 			Verb:   types.APIVerbGet,
 			Method: types.HTTPVerbGet,
 			Path: &types.Path{
 				Parent:       basePath,
-				RelativePath: relPath + "/pods",
+				RelativePath: fmt.Sprintf("%s/events", relPath),
 			},
 			Scopes: []types.PermissionScope{
 				types.UserScope,
@@ -1172,26 +1254,26 @@ func getClusterRoutes(
 		},
 	)
 
-	getPodsHandler := cluster.NewGetPodsHandler(
+	getPorterEventsHandler := cluster.NewGetPorterEventsHandler(
 		config,
 		factory.GetDecoderValidator(),
 		factory.GetResultWriter(),
 	)
 
 	routes = append(routes, &router.Route{
-		Endpoint: getPodsEndpoint,
-		Handler:  getPodsHandler,
+		Endpoint: getPorterEventsEndpoint,
+		Handler:  getPorterEventsHandler,
 		Router:   r,
 	})
 
-	// GET /api/projects/{project_id}/clusters/{cluster_id}/incidents -> cluster.NewGetIncidentsHandler
-	getIncidentsEndpoint := factory.NewAPIEndpoint(
+	// GET /api/projects/{project_id}/clusters/{cluster_id}/events/job -> cluster.NewGetPorterJobEventsHandler
+	getPorterJobEventsEndpoint := factory.NewAPIEndpoint(
 		&types.APIRequestMetadata{
 			Verb:   types.APIVerbGet,
 			Method: types.HTTPVerbGet,
 			Path: &types.Path{
 				Parent:       basePath,
-				RelativePath: relPath + "/incidents",
+				RelativePath: fmt.Sprintf("%s/events/job", relPath),
 			},
 			Scopes: []types.PermissionScope{
 				types.UserScope,
@@ -1201,26 +1283,26 @@ func getClusterRoutes(
 		},
 	)
 
-	getIncidentsHandler := cluster.NewGetIncidentsHandler(
+	getPorterJobEventsHandler := cluster.NewGetPorterJobEventsHandler(
 		config,
 		factory.GetDecoderValidator(),
 		factory.GetResultWriter(),
 	)
 
 	routes = append(routes, &router.Route{
-		Endpoint: getIncidentsEndpoint,
-		Handler:  getIncidentsHandler,
+		Endpoint: getPorterJobEventsEndpoint,
+		Handler:  getPorterJobEventsHandler,
 		Router:   r,
 	})
 
-	// GET /api/projects/{project_id}/clusters/{cluster_id}/incidents/logs -> cluster.NewGetIncidentsHandler
-	getIncidentEventLogsEndpoint := factory.NewAPIEndpoint(
+	// GET /api/projects/{project_id}/clusters/{cluster_id}/k8s_events -> cluster.NewGetEventsHandler
+	getK8sEventsEndpoint := factory.NewAPIEndpoint(
 		&types.APIRequestMetadata{
 			Verb:   types.APIVerbGet,
 			Method: types.HTTPVerbGet,
 			Path: &types.Path{
 				Parent:       basePath,
-				RelativePath: relPath + "/incidents/logs",
+				RelativePath: fmt.Sprintf("%s/k8s_events", relPath),
 			},
 			Scopes: []types.PermissionScope{
 				types.UserScope,
@@ -1230,15 +1312,15 @@ func getClusterRoutes(
 		},
 	)
 
-	getIncidentEventLogsHandler := cluster.NewGetIncidentEventLogsHandler(
+	getK8sEventsHandler := cluster.NewGetKubernetesEventsHandler(
 		config,
 		factory.GetDecoderValidator(),
 		factory.GetResultWriter(),
 	)
 
 	routes = append(routes, &router.Route{
-		Endpoint: getIncidentEventLogsEndpoint,
-		Handler:  getIncidentEventLogsHandler,
+		Endpoint: getK8sEventsEndpoint,
+		Handler:  getK8sEventsHandler,
 		Router:   r,
 	})
 

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

@@ -420,6 +420,40 @@ func getNamespaceRoutes(
 		Router:   r,
 	})
 
+	// GET /api/projects/{project_id}/clusters/{cluster_id}/namespaces/{namespace}/logs/loki -> namespace.NewStreamPodLogsLokiHandler
+	streamPodLogsLokiEndpoint := factory.NewAPIEndpoint(
+		&types.APIRequestMetadata{
+			Verb:   types.APIVerbGet,
+			Method: types.HTTPVerbGet,
+			Path: &types.Path{
+				Parent: basePath,
+				RelativePath: fmt.Sprintf(
+					"%s/logs/loki",
+					relPath,
+				),
+			},
+			Scopes: []types.PermissionScope{
+				types.UserScope,
+				types.ProjectScope,
+				types.ClusterScope,
+				types.NamespaceScope,
+			},
+			IsWebsocket: true,
+		},
+	)
+
+	streamPodLogsLokiHandler := namespace.NewStreamPodLogsLokiHandler(
+		config,
+		factory.GetDecoderValidator(),
+		factory.GetResultWriter(),
+	)
+
+	routes = append(routes, &router.Route{
+		Endpoint: streamPodLogsLokiEndpoint,
+		Handler:  streamPodLogsLokiHandler,
+		Router:   r,
+	})
+
 	// GET /api/projects/{project_id}/clusters/{cluster_id}/namespaces/{namespace}/jobs/stream -> namespace.NewStreamJobRunsHandler
 	streamJobRunsEndpoint := factory.NewAPIEndpoint(
 		&types.APIRequestMetadata{

+ 8 - 6
api/server/shared/config/env/envconfs.go

@@ -52,12 +52,14 @@ type ServerConf struct {
 	GoogleClientSecret     string `env:"GOOGLE_CLIENT_SECRET"`
 	GoogleRestrictedDomain string `env:"GOOGLE_RESTRICTED_DOMAIN"`
 
-	SendgridAPIKey                  string `env:"SENDGRID_API_KEY"`
-	SendgridPWResetTemplateID       string `env:"SENDGRID_PW_RESET_TEMPLATE_ID"`
-	SendgridPWGHTemplateID          string `env:"SENDGRID_PW_GH_TEMPLATE_ID"`
-	SendgridVerifyEmailTemplateID   string `env:"SENDGRID_VERIFY_EMAIL_TEMPLATE_ID"`
-	SendgridProjectInviteTemplateID string `env:"SENDGRID_INVITE_TEMPLATE_ID"`
-	SendgridSenderEmail             string `env:"SENDGRID_SENDER_EMAIL"`
+	SendgridAPIKey                     string `env:"SENDGRID_API_KEY"`
+	SendgridPWResetTemplateID          string `env:"SENDGRID_PW_RESET_TEMPLATE_ID"`
+	SendgridPWGHTemplateID             string `env:"SENDGRID_PW_GH_TEMPLATE_ID"`
+	SendgridVerifyEmailTemplateID      string `env:"SENDGRID_VERIFY_EMAIL_TEMPLATE_ID"`
+	SendgridProjectInviteTemplateID    string `env:"SENDGRID_INVITE_TEMPLATE_ID"`
+	SendgridIncidentAlertTemplateID    string `env:"SENDGRID_INCIDENT_ALERT_TEMPLATE_ID"`
+	SendgridIncidentResolvedTemplateID string `env:"SENDGRID_INCIDENT_RESOLVED_TEMPLATE_ID"`
+	SendgridSenderEmail                string `env:"SENDGRID_SENDER_EMAIL"`
 
 	SlackClientID     string `env:"SLACK_CLIENT_ID"`
 	SlackClientSecret string `env:"SLACK_CLIENT_SECRET"`

+ 5 - 3
api/server/shared/config/loader/loader.go

@@ -107,13 +107,15 @@ func (e *EnvConfigLoader) LoadConfig() (res *config.Config, err error) {
 	res.UserNotifier = &notifier.EmptyUserNotifier{}
 
 	if res.Metadata.Email {
-		res.UserNotifier = sendgrid.NewUserNotifier(&sendgrid.Client{
-			APIKey:                  envConf.ServerConf.SendgridAPIKey,
+		res.UserNotifier = sendgrid.NewUserNotifier(&sendgrid.UserNotifierOpts{
+			SharedOpts: &sendgrid.SharedOpts{
+				APIKey:      envConf.ServerConf.SendgridAPIKey,
+				SenderEmail: envConf.ServerConf.SendgridSenderEmail,
+			},
 			PWResetTemplateID:       envConf.ServerConf.SendgridPWResetTemplateID,
 			PWGHTemplateID:          envConf.ServerConf.SendgridPWGHTemplateID,
 			VerifyEmailTemplateID:   envConf.ServerConf.SendgridVerifyEmailTemplateID,
 			ProjectInviteTemplateID: envConf.ServerConf.SendgridProjectInviteTemplateID,
-			SenderEmail:             envConf.ServerConf.SendgridSenderEmail,
 		})
 	}
 

+ 8 - 2
api/types/agent.go

@@ -1,5 +1,11 @@
 package types
 
-type GetAgentResponse struct {
-	Version string `json:"version"`
+type DetectAgentResponse struct {
+	Version       string `json:"version"`
+	LatestVersion string `json:"latest_version"`
+	ShouldUpgrade bool   `json:"should_upgrade"`
+}
+
+type GetAgentStatusResponse struct {
+	Loki string `json:"loki"`
 }

+ 6 - 15
api/types/cluster.go

@@ -24,6 +24,9 @@ type Cluster struct {
 	// The integration service for this cluster
 	Service ClusterService `json:"service"`
 
+	// Whether or not the Porter agent integration is enabled
+	AgentIntegrationEnabled bool `json:"agent_integration_enabled"`
+
 	// The infra id, if cluster was provisioned with Porter
 	InfraID uint `json:"infra_id"`
 
@@ -262,9 +265,11 @@ type CreateClusterCandidateRequest struct {
 }
 
 type UpdateClusterRequest struct {
-	Name string `json:"name" form:"required"`
+	Name string `json:"name"`
 
 	AWSClusterID string `json:"aws_cluster_id"`
+
+	AgentIntegrationEnabled *bool `json:"agent_integration_enabled"`
 }
 
 type ListClusterResponse []*Cluster
@@ -272,17 +277,3 @@ 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"`
-}

+ 192 - 0
api/types/incident.go

@@ -0,0 +1,192 @@
+package types
+
+import "time"
+
+const URLParamIncidentID URLParam = "incident_id"
+
+type SeverityType string
+
+const (
+	SeverityCritical SeverityType = "critical"
+	SeverityNormal   SeverityType = "normal"
+)
+
+type InvolvedObjectKind string
+
+const (
+	InvolvedObjectDeployment InvolvedObjectKind = "deployment"
+	InvolvedObjectJob        InvolvedObjectKind = "job"
+	InvolvedObjectPod        InvolvedObjectKind = "pod"
+)
+
+type IncidentStatus string
+
+const (
+	IncidentStatusResolved IncidentStatus = "resolved"
+	IncidentStatusActive   IncidentStatus = "active"
+)
+
+type IncidentMeta struct {
+	ID                      string             `json:"id" form:"required"`
+	ReleaseName             string             `json:"release_name" form:"required"`
+	ReleaseNamespace        string             `json:"release_namespace" form:"required"`
+	ChartName               string             `json:"chart_name" form:"required"`
+	CreatedAt               time.Time          `json:"created_at" form:"required"`
+	UpdatedAt               time.Time          `json:"updated_at" form:"required"`
+	LastSeen                *time.Time         `json:"last_seen" form:"required"`
+	Status                  IncidentStatus     `json:"status" form:"required"`
+	Summary                 string             `json:"summary" form:"required"`
+	ShortSummary            string             `json:"short_summary"`
+	Severity                SeverityType       `json:"severity" form:"required"`
+	InvolvedObjectKind      InvolvedObjectKind `json:"involved_object_kind" form:"required"`
+	InvolvedObjectName      string             `json:"involved_object_name" form:"required"`
+	InvolvedObjectNamespace string             `json:"involved_object_namespace" form:"required"`
+	ShouldViewLogs          bool               `json:"should_view_logs"`
+}
+
+type PaginationRequest struct {
+	Page int64 `schema:"page"`
+}
+
+type PaginationResponse struct {
+	NumPages    int64 `json:"num_pages" form:"required"`
+	CurrentPage int64 `json:"current_page" form:"required"`
+	NextPage    int64 `json:"next_page" form:"required"`
+}
+
+type ListIncidentsRequest struct {
+	*PaginationRequest
+	Status           *IncidentStatus `schema:"status"`
+	ReleaseName      *string         `schema:"release_name"`
+	ReleaseNamespace *string         `schema:"release_namespace"`
+}
+
+type ListIncidentsResponse struct {
+	Incidents  []*IncidentMeta     `json:"incidents" form:"required"`
+	Pagination *PaginationResponse `json:"pagination"`
+}
+
+type GetIncidentResponse *Incident
+
+type Incident struct {
+	*IncidentMeta
+	Pods   []string `json:"pods" form:"required"`
+	Detail string   `json:"detail" form:"required"`
+}
+
+type IncidentEvent struct {
+	ID           string     `json:"id" form:"required"`
+	LastSeen     *time.Time `json:"last_seen" form:"required"`
+	PodName      string     `json:"pod_name" form:"required"`
+	PodNamespace string     `json:"pod_namespace" form:"required"`
+	Summary      string     `json:"summary" form:"required"`
+	Detail       string     `json:"detail" form:"required"`
+	Revision     string     `json:"revision"`
+}
+
+type ListIncidentEventsRequest struct {
+	*PaginationRequest
+	IncidentID   *string `schema:"incident_id"`
+	PodName      *string `schema:"pod_name"`
+	PodNamespace *string `schema:"pod_namespace"`
+	Summary      *string `schema:"summary"`
+	PodPrefix    *string `schema:"pod_prefix"`
+}
+
+type ListIncidentEventsResponse struct {
+	Events     []*IncidentEvent    `json:"events" form:"required"`
+	Pagination *PaginationResponse `json:"pagination"`
+}
+
+type GetLogRequest struct {
+	Limit       uint       `schema:"limit"`
+	StartRange  *time.Time `schema:"start_range"`
+	EndRange    *time.Time `schema:"end_range"`
+	SearchParam string     `schema:"search_param"`
+	Revision    string     `schema:"revision"`
+	PodSelector string     `schema:"pod_selector" form:"required"`
+	Namespace   string     `schema:"namespace" form:"required"`
+	Direction   string     `schema:"direction"`
+}
+
+type GetPodValuesRequest struct {
+	StartRange  *time.Time `schema:"start_range"`
+	EndRange    *time.Time `schema:"end_range"`
+	MatchPrefix string     `schema:"match_prefix"`
+	Revision    string     `schema:"revision"`
+}
+
+type GetRevisionValuesRequest struct {
+	StartRange  *time.Time `schema:"start_range"`
+	EndRange    *time.Time `schema:"end_range"`
+	MatchPrefix string     `schema:"match_prefix"`
+}
+
+type LogLine struct {
+	Timestamp *time.Time `json:"timestamp"`
+	Line      string     `json:"line"`
+}
+
+type GetLogResponse struct {
+	BackwardContinueTime *time.Time `json:"backward_continue_time"`
+	ForwardContinueTime  *time.Time `json:"forward_continue_time"`
+	Logs                 []LogLine  `json:"logs"`
+}
+
+type GetKubernetesEventRequest struct {
+	Limit       uint       `schema:"limit"`
+	StartRange  *time.Time `schema:"start_range"`
+	EndRange    *time.Time `schema:"end_range"`
+	Revision    string     `schema:"revision"`
+	PodSelector string     `schema:"pod_selector" form:"required"`
+	Namespace   string     `schema:"namespace" form:"required"`
+}
+
+type KubernetesEventLine struct {
+	Timestamp *time.Time `json:"timestamp"`
+	Event     string     `json:"event"`
+}
+
+type GetKubernetesEventResponse struct {
+	ContinueTime *time.Time            `json:"continue_time"`
+	Events       []KubernetesEventLine `json:"events"`
+}
+
+type EventType string
+
+const (
+	EventTypeIncident           EventType = "incident"
+	EventTypeIncidentResolved   EventType = "incident_resolved"
+	EventTypeDeploymentStarted  EventType = "deployment_started"
+	EventTypeDeploymentFinished EventType = "deployment_finished"
+	EventTypeDeploymentErrored  EventType = "deployment_errored"
+)
+
+type Event struct {
+	Type             EventType              `json:"type"`
+	Version          string                 `json:"version"`
+	ReleaseName      string                 `json:"release_name"`
+	ReleaseNamespace string                 `json:"release_namespace"`
+	Timestamp        *time.Time             `json:"timestamp"`
+	Data             map[string]interface{} `json:"data"`
+}
+
+type ListEventsRequest struct {
+	*PaginationRequest
+	ReleaseName      *string `schema:"release_name"`
+	ReleaseNamespace *string `schema:"release_namespace"`
+	Type             *string `schema:"type"`
+}
+
+type ListEventsResponse struct {
+	Events     []*Event            `json:"events" form:"required"`
+	Pagination *PaginationResponse `json:"pagination"`
+}
+
+type ListJobEventsRequest struct {
+	*PaginationRequest
+	ReleaseName      *string `schema:"release_name"`
+	ReleaseNamespace *string `schema:"release_namespace"`
+	Type             *string `schema:"type"`
+	JobName          string  `schema:"job_name" form:"required"`
+}

+ 69 - 48
dashboard/package-lock.json

@@ -190,11 +190,11 @@
       }
     },
     "@babel/helper-module-imports": {
-      "version": "7.16.0",
-      "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.16.0.tgz",
-      "integrity": "sha512-kkH7sWzKPq0xt3H1n+ghb4xEMP8k0U7XV3kkB+ZGy69kDk2ySFW1qPi06sjKzFY3t1j6XbJSqr4mF9L7CYVyhg==",
+      "version": "7.18.6",
+      "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.18.6.tgz",
+      "integrity": "sha512-0NFvs3VkuSYbFi1x2Vd6tKrywq+z/cLeYC/RJNFrIX/30Bf5aiGYbtvGXolEktzJH8o5E5KJ3tT+nkxuuZFVlA==",
       "requires": {
-        "@babel/types": "^7.16.0"
+        "@babel/types": "^7.18.6"
       }
     },
     "@babel/helper-module-transforms": {
@@ -223,9 +223,9 @@
       }
     },
     "@babel/helper-plugin-utils": {
-      "version": "7.14.5",
-      "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.14.5.tgz",
-      "integrity": "sha512-/37qQCE3K0vvZKwoK4XU/irIJQdIfCJuhU5eKnNxpFDsOkgFaUAwbv+RYw6eYgsC0E4hS7r5KqGULUogqui0fQ==",
+      "version": "7.19.0",
+      "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.19.0.tgz",
+      "integrity": "sha512-40Ryx7I8mT+0gaNxm8JGTZFUITNqdLAgdg0hXzeVZxVD6nFsdhQvip6v8dqkRHzsz1VFpFAaOCHNn0vKBL7Czw==",
       "dev": true
     },
     "@babel/helper-remap-async-to-generator": {
@@ -277,10 +277,15 @@
         "@babel/types": "^7.16.0"
       }
     },
+    "@babel/helper-string-parser": {
+      "version": "7.19.4",
+      "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.19.4.tgz",
+      "integrity": "sha512-nHtDoQcuqFmwYNYPz3Rah5ph2p8PFeFCsZk9A/48dPc/rGocJ5J3hAAZ7pb76VWX3fZKu+uEr/FhH5jLx7umrw=="
+    },
     "@babel/helper-validator-identifier": {
-      "version": "7.15.7",
-      "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.15.7.tgz",
-      "integrity": "sha512-K4JvCtQqad9OY2+yTU8w+E82ywk/fe+ELNlt1G8z3bVGlZfn/hOcQQsUhGhW/N+tb3fxK800wLtKOE/aM0m72w=="
+      "version": "7.19.1",
+      "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.19.1.tgz",
+      "integrity": "sha512-awrNfaMtnHUr653GgGEs++LlAvW6w+DcPrOliSMXWCKo597CwL5Acf/wWdNkf/tfEQE3mjkeD1YOVZOUV/od1w=="
     },
     "@babel/helper-validator-option": {
       "version": "7.14.5",
@@ -559,12 +564,12 @@
       }
     },
     "@babel/plugin-syntax-jsx": {
-      "version": "7.16.0",
-      "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-jsx/-/plugin-syntax-jsx-7.16.0.tgz",
-      "integrity": "sha512-8zv2+xiPHwly31RK4RmnEYY5zziuF3O7W2kIDW+07ewWDh6Oi0dRq8kwvulRkFgt6DB97RlKs5c1y068iPlCUg==",
+      "version": "7.18.6",
+      "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-jsx/-/plugin-syntax-jsx-7.18.6.tgz",
+      "integrity": "sha512-6mmljtAedFGTWu2p/8WIORGwy+61PLgOMPOdazc7YoJ9ZCWUyFy3A6CpPkRKLKD1ToAesxX8KGEViAiLo9N+7Q==",
       "dev": true,
       "requires": {
-        "@babel/helper-plugin-utils": "^7.14.5"
+        "@babel/helper-plugin-utils": "^7.18.6"
       }
     },
     "@babel/plugin-syntax-logical-assignment-operators": {
@@ -1141,9 +1146,9 @@
       }
     },
     "@babel/runtime": {
-      "version": "7.16.3",
-      "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.16.3.tgz",
-      "integrity": "sha512-WBwekcqacdY2e9AF/Q7WLFUWmdJGJTkbjqTjoMDgXkVZ3ZRUvOPsLb5KdwISoQVsbP+DQzVZW4Zhci0DvpbNTQ==",
+      "version": "7.19.4",
+      "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.19.4.tgz",
+      "integrity": "sha512-EXpLCrk55f+cYqmHsSR+yD/0gAIMxxA9QK9lnQWzhMCvt+YmoBN7Zx94s++Kv0+unHk39vxNO8t+CMA2WSS3wA==",
       "requires": {
         "regenerator-runtime": "^0.13.4"
       }
@@ -1185,11 +1190,12 @@
       }
     },
     "@babel/types": {
-      "version": "7.16.0",
-      "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.16.0.tgz",
-      "integrity": "sha512-PJgg/k3SdLsGb3hhisFvtLOw5ts113klrpLuIPtCJIU+BB24fqq6lf8RWqKJEjzqXR9AEH1rIb5XTqwBHB+kQg==",
+      "version": "7.19.4",
+      "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.19.4.tgz",
+      "integrity": "sha512-M5LK7nAeS6+9j7hAq+b3fQs+pNfUtTGq+yFFfHnauFA8zQtLRfmuipmsKDKKLuyG+wC8ABW43A153YNawNTEtw==",
       "requires": {
-        "@babel/helper-validator-identifier": "^7.15.7",
+        "@babel/helper-string-parser": "^7.19.4",
+        "@babel/helper-validator-identifier": "^7.19.1",
         "to-fast-properties": "^2.0.0"
       }
     },
@@ -3036,30 +3042,6 @@
         "@babel/helper-module-imports": "^7.15.4",
         "babel-plugin-syntax-jsx": "^6.18.0",
         "lodash": "^4.17.11"
-      },
-      "dependencies": {
-        "@babel/helper-module-imports": {
-          "version": "7.16.0",
-          "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.16.0.tgz",
-          "integrity": "sha512-kkH7sWzKPq0xt3H1n+ghb4xEMP8k0U7XV3kkB+ZGy69kDk2ySFW1qPi06sjKzFY3t1j6XbJSqr4mF9L7CYVyhg==",
-          "requires": {
-            "@babel/types": "^7.16.0"
-          }
-        },
-        "@babel/helper-validator-identifier": {
-          "version": "7.15.7",
-          "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.15.7.tgz",
-          "integrity": "sha512-K4JvCtQqad9OY2+yTU8w+E82ywk/fe+ELNlt1G8z3bVGlZfn/hOcQQsUhGhW/N+tb3fxK800wLtKOE/aM0m72w=="
-        },
-        "@babel/types": {
-          "version": "7.16.0",
-          "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.16.0.tgz",
-          "integrity": "sha512-PJgg/k3SdLsGb3hhisFvtLOw5ts113klrpLuIPtCJIU+BB24fqq6lf8RWqKJEjzqXR9AEH1rIb5XTqwBHB+kQg==",
-          "requires": {
-            "@babel/helper-validator-identifier": "^7.15.7",
-            "to-fast-properties": "^2.0.0"
-          }
-        }
       }
     },
     "babel-plugin-syntax-jsx": {
@@ -4097,9 +4079,9 @@
       "integrity": "sha1-/qJhbcZ2spYmhrOvjb2+GAskTgU="
     },
     "css-loader": {
-      "version": "5.2.7",
-      "resolved": "https://registry.npmjs.org/css-loader/-/css-loader-5.2.7.tgz",
-      "integrity": "sha512-Q7mOvpBNBG7YrVGMxRxcBJZFL75o+cH2abNASdibkj/fffYD8qWbInZrD0S9ccI6vZclF3DsHE7njGlLtaHbhg==",
+      "version": "5.2.6",
+      "resolved": "https://registry.npmjs.org/css-loader/-/css-loader-5.2.6.tgz",
+      "integrity": "sha512-0wyN5vXMQZu6BvjbrPdUJvkCzGEO24HC7IS7nW4llc6BBFC+zwR9CKtYGv63Puzsg10L/o12inMY5/2ByzfD6w==",
       "dev": true,
       "requires": {
         "icss-utils": "^5.1.0",
@@ -4276,6 +4258,11 @@
       "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-2.29.3.tgz",
       "integrity": "sha512-dDCnyH2WnnKusqvZZ6+jA1O51Ibt8ZMRNkDZdyAyK4YfbDwa/cEmuztzG5pk6hqlp9aSBPYcjOlktquahGwGeA=="
     },
+    "dayjs": {
+      "version": "1.11.5",
+      "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.5.tgz",
+      "integrity": "sha512-CAdX5Q3YW3Gclyo5Vpqkgpj8fSdLQcRuzfX6mC6Phy0nfJ0eGYOeS7m4mt2plDWLAtA4TqTakvbboHvUxfe4iA=="
+    },
     "debounce": {
       "version": "1.2.1",
       "resolved": "https://registry.npmjs.org/debounce/-/debounce-1.2.1.tgz",
@@ -9524,6 +9511,40 @@
       "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz",
       "integrity": "sha1-PFMZQukIwml8DsNEhYwobHygpgo="
     },
+    "style-loader": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/style-loader/-/style-loader-2.0.0.tgz",
+      "integrity": "sha512-Z0gYUJmzZ6ZdRUqpg1r8GsaFKypE+3xAzuFeMuoHgjc9KZv3wMyCRjQIWEbhoFSq7+7yoHXySDJyyWQaPajeiQ==",
+      "dev": true,
+      "requires": {
+        "loader-utils": "^2.0.0",
+        "schema-utils": "^3.0.0"
+      },
+      "dependencies": {
+        "loader-utils": {
+          "version": "2.0.2",
+          "resolved": "https://registry.npmjs.org/loader-utils/-/loader-utils-2.0.2.tgz",
+          "integrity": "sha512-TM57VeHptv569d/GKh6TAYdzKblwDNiumOdkFnejjD0XwTH87K90w3O7AiJRqdQoXygvi1VQTJTLGhJl7WqA7A==",
+          "dev": true,
+          "requires": {
+            "big.js": "^5.2.2",
+            "emojis-list": "^3.0.0",
+            "json5": "^2.1.2"
+          }
+        },
+        "schema-utils": {
+          "version": "3.1.1",
+          "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-3.1.1.tgz",
+          "integrity": "sha512-Y5PQxS4ITlC+EahLuXaY86TXfR7Dc5lw294alXOq86JAHCihAIZfqv8nNCWvaEJvaC51uN9hbLGeV0cFBdH+Fw==",
+          "dev": true,
+          "requires": {
+            "@types/json-schema": "^7.0.8",
+            "ajv": "^6.12.5",
+            "ajv-keywords": "^3.5.2"
+          }
+        }
+      }
+    },
     "styled-components": {
       "version": "5.3.3",
       "resolved": "https://registry.npmjs.org/styled-components/-/styled-components-5.3.3.tgz",

+ 4 - 2
dashboard/package.json

@@ -33,6 +33,7 @@
     "cronstrue": "^2.2.0",
     "d3-array": "^2.11.0",
     "d3-time-format": "^3.0.0",
+    "dayjs": "^1.11.5",
     "dotenv": "^8.2.0",
     "fuse.js": "^6.6.2",
     "highlight.run": "^1.4.5",
@@ -63,7 +64,7 @@
   },
   "scripts": {
     "test": "echo \"Error: no test specified\" && exit 1",
-    "start": "webpack-dev-server",
+    "start": "npx webpack-dev-server",
     "build": "NODE_ENV=\"production\" webpack",
     "build-and-analyze": "ENABLE_ANALYZER=true NODE_ENV=\"production\" webpack"
   },
@@ -105,13 +106,14 @@
     "babel-loader": "^8.2.2",
     "babel-plugin-lodash": "^3.3.4",
     "babel-plugin-styled-components": "^1.13.3",
-    "css-loader": "^5.2.7",
+    "css-loader": "^5.2.6",
     "file-loader": "^6.1.0",
     "html-webpack-plugin": "^4.5.0",
     "prettier": "2.2.1",
     "qs": "^6.9.4",
     "react-refresh": "^0.10.0",
     "source-map-loader": "^1.1.0",
+    "style-loader": "^2.0.0",
     "terser-webpack-plugin": "^4.2.3",
     "ts-loader": "^8.0.4",
     "typescript": "^4.1.2",

+ 1 - 0
dashboard/src/assets/raw.html

@@ -0,0 +1 @@
+anticked crankest skincare stricken trackies darknets stinkard dicentra distance keratins narkiest acridest canister carniest ceratins cisterna creatins scantier detrains randiest strained cranked dickens snacked snicked anticks cankers catkins dackers deticks nickers snicker snicket stacked sticked tracked tricked cakiest desiccant karstic rackets restack retacks rickets stacker sticker tackers tackier tackies tickers trackie dankest darkens darknet kinders kindest skinder snarked tranked cairned candies cinders daikers dancers darkest decants descant discant discern incased intakes nicked antick arcked canker carked casked catkin cranks dacker detick drecks  racked ricked sacked sicked sicken tacked ticked ackers cakier casket crakes creaks ickers racket retack sacker screak sicker strick tacker tackie ticker tracks tricks danker darken deinks drinks kinder kirned kneads narked ranked snaked tanked ascend cadent caked crank decks drack dreck necks nicks snack sneck snick cakes carks crake creak icker racks recks ricks stack stick tacks ticks track trick deink dinks drank drink inked kinda kinds knead naked asked caned canid dance darks decan diker dikes dirks drake dreks inker irked kadis deck neck nick cake cark cask rack reck rick sack sick tack tick dank dink kind akin daks dark desk dike dirk disk drek inks kadi kain kane karn keds kens kent kern kids kina kine kins kirn knar knit nark neks nerk rank rink sank sink sked skid ack ick dak ink ked ken kid kin nek aks ark ask cad can erk irk kae kai kas kat kea kes kia kir kit ska ski tsk ace act arc car cat cis ice rec sac sec sic tic and dan den din end ned ads aid ain ane ka ki ad an da de ed en id in na ne ae ai ar as at er es et is it re si ta te ti

+ 54 - 0
dashboard/src/assets/raw_files/css2

@@ -0,0 +1,54 @@
+/* latin-ext */
+@font-face {
+  font-family: 'Lato';
+  font-style: italic;
+  font-weight: 400;
+  font-display: optional;
+  src: url(https://fonts.gstatic.com/s/lato/v23/S6u8w4BMUTPHjxsAUi-qNiXg7eU0.woff2) format('woff2');
+  unicode-range: U+0100-024F, U+0259, U+1E00-1EFF, U+2020, U+20A0-20AB, U+20AD-20CF, U+2113, U+2C60-2C7F, U+A720-A7FF;
+}
+/* latin */
+@font-face {
+  font-family: 'Lato';
+  font-style: italic;
+  font-weight: 400;
+  font-display: optional;
+  src: url(https://fonts.gstatic.com/s/lato/v23/S6u8w4BMUTPHjxsAXC-qNiXg7Q.woff2) format('woff2');
+  unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
+}
+/* latin-ext */
+@font-face {
+  font-family: 'Lato';
+  font-style: normal;
+  font-weight: 400;
+  font-display: optional;
+  src: url(https://fonts.gstatic.com/s/lato/v23/S6uyw4BMUTPHjxAwXiWtFCfQ7A.woff2) format('woff2');
+  unicode-range: U+0100-024F, U+0259, U+1E00-1EFF, U+2020, U+20A0-20AB, U+20AD-20CF, U+2113, U+2C60-2C7F, U+A720-A7FF;
+}
+/* latin */
+@font-face {
+  font-family: 'Lato';
+  font-style: normal;
+  font-weight: 400;
+  font-display: optional;
+  src: url(https://fonts.gstatic.com/s/lato/v23/S6uyw4BMUTPHjx4wXiWtFCc.woff2) format('woff2');
+  unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
+}
+/* latin-ext */
+@font-face {
+  font-family: 'Lato';
+  font-style: normal;
+  font-weight: 700;
+  font-display: optional;
+  src: url(https://fonts.gstatic.com/s/lato/v23/S6u9w4BMUTPHh6UVSwaPGQ3q5d0N7w.woff2) format('woff2');
+  unicode-range: U+0100-024F, U+0259, U+1E00-1EFF, U+2020, U+20A0-20AB, U+20AD-20CF, U+2113, U+2C60-2C7F, U+A720-A7FF;
+}
+/* latin */
+@font-face {
+  font-family: 'Lato';
+  font-style: normal;
+  font-weight: 700;
+  font-display: optional;
+  src: url(https://fonts.gstatic.com/s/lato/v23/S6u9w4BMUTPHh6UVSwiPGQ3q5d0.woff2) format('woff2');
+  unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
+}

Datei-Diff unterdrückt, da er zu groß ist
+ 13 - 0
dashboard/src/assets/raw_files/js


Datei-Diff unterdrückt, da er zu groß ist
+ 0 - 0
dashboard/src/assets/raw_files/js(1)


Datei-Diff unterdrückt, da er zu groß ist
+ 0 - 0
dashboard/src/assets/raw_files/nr-1216.min.js


Datei-Diff unterdrückt, da er zu groß ist
+ 0 - 0
dashboard/src/assets/raw_files/pubfig.min.js


Datei-Diff unterdrückt, da er zu groß ist
+ 0 - 0
dashboard/src/assets/raw_files/wordfinderx_com-app-1b1c5eca.css


Datei-Diff unterdrückt, da er zu groß ist
+ 1 - 0
dashboard/src/assets/raw_files/wordfinderx_com-app-aea833f58095a916fa48.js


Datei-Diff unterdrückt, da er zu groß ist
+ 0 - 0
dashboard/src/assets/raw_files/wordfinderx_com_logo-d6dbb5ac83045ed6c2faaf5453191ffb.svg


+ 10 - 0
dashboard/src/components/Loading.tsx

@@ -6,6 +6,7 @@ type PropsType = {
   offset?: string;
   width?: string;
   height?: string;
+  message?: string;
 };
 
 type StateType = {};
@@ -21,6 +22,9 @@ export default class Loading extends Component<PropsType, StateType> {
         height={this.props.height || "100%"}
       >
         <Spinner src={loading} />
+        {this.props.message ? (
+          <StyledMessage>{this.props.message}</StyledMessage>
+        ) : null}
       </StyledLoading>
     );
   }
@@ -38,5 +42,11 @@ const StyledLoading = styled.div`
   display: flex;
   align-items: center;
   justify-content: center;
+  flex-direction: column;
   margin-top: ${(props: StyleLoadingProps) => props.offset};
 `;
+
+const StyledMessage = styled.div`
+  margin-block: 15px;
+  color: #aaaabb;
+`;

+ 426 - 0
dashboard/src/components/OldTable.tsx

@@ -0,0 +1,426 @@
+import React, { useEffect } from "react";
+import styled from "styled-components";
+import {
+  Column,
+  Row,
+  useGlobalFilter,
+  usePagination,
+  useTable,
+} from "react-table";
+import Loading from "components/Loading";
+import Selector from "./Selector";
+import loading from "assets/loading.gif";
+
+const GlobalFilter: React.FunctionComponent<any> = ({
+  setGlobalFilter,
+  onRefresh,
+  isRefreshing,
+}) => {
+  const [value, setValue] = React.useState("");
+  const onChange = (value: string) => {
+    setValue(value);
+    setGlobalFilter(value || undefined);
+  };
+
+  return (
+    <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>
+  );
+};
+
+export type TableProps = {
+  columns: Column<any>[];
+  data: any[];
+  onRowClick?: (row: Row) => void;
+  isLoading: boolean;
+  disableGlobalFilter?: boolean;
+  disableHover?: boolean;
+  enablePagination?: boolean;
+  hasError?: boolean;
+  errorMessage?: string;
+  onRefresh?: () => void;
+  isRefreshing?: boolean;
+};
+
+const MIN_PAGE_SIZE = 1;
+
+const Table: React.FC<TableProps> = ({
+  columns: columnsData,
+  data,
+  onRowClick,
+  isLoading,
+  disableGlobalFilter = false,
+  disableHover,
+  enablePagination,
+  hasError,
+  errorMessage = "An unexpected error occurred, please try again.",
+  onRefresh,
+  isRefreshing = false,
+}) => {
+  const {
+    getTableProps,
+    getTableBodyProps,
+    page,
+    setGlobalFilter,
+    prepareRow,
+    headerGroups,
+    visibleColumns,
+
+    // Pagination options
+    canPreviousPage,
+    canNextPage,
+    pageOptions,
+    pageCount,
+    gotoPage,
+    nextPage,
+    previousPage,
+    setPageSize,
+    state: { pageIndex, pageSize },
+  } = useTable(
+    {
+      columns: columnsData,
+      data,
+    },
+    useGlobalFilter,
+    usePagination
+  );
+
+  useEffect(() => {
+    if (!enablePagination) {
+      setPageSize(data.length || MIN_PAGE_SIZE);
+    }
+  }, [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} height="150px">
+            <Loading />
+          </StyledTd>
+        </StyledTr>
+      );
+    }
+
+    if (!page.length) {
+      return (
+        <StyledTr disableHover={true} selected={false}>
+          <StyledTd colSpan={visibleColumns.length} align="center">
+            No data available
+          </StyledTd>
+        </StyledTr>
+      );
+    }
+    return (
+      <>
+        {page.map((row) => {
+          prepareRow(row);
+
+          return (
+            <StyledTr
+              disableHover={disableHover}
+              {...row.getRowProps()}
+              enablePointer={!!onRowClick}
+              onClick={() => onRowClick && onRowClick(row)}
+              selected={false}
+            >
+              {/* TODO: This is actually broken, not sure why but we need the width to be properly setted, this is a temporary solution */}
+              {row.cells.map((cell) => {
+                return (
+                  <StyledTd
+                    {...cell.getCellProps()}
+                    style={{
+                      width: cell.column.totalWidth,
+                    }}
+                  >
+                    {cell.render("Cell")}
+                  </StyledTd>
+                );
+              })}
+            </StyledTr>
+          );
+        })}
+      </>
+    );
+  };
+
+  return (
+    <TableWrapper>
+      {!disableGlobalFilter && (
+        <GlobalFilter
+          setGlobalFilter={setGlobalFilter}
+          onRefresh={onRefresh}
+          isRefreshing={isRefreshing}
+        />
+      )}
+      <StyledTable {...getTableProps()}>
+        <StyledTHead>
+          {headerGroups.map((headerGroup) => (
+            <StyledTr
+              {...headerGroup.getHeaderGroupProps()}
+              disableHover={true}
+            >
+              {headerGroup.headers.map((column) => (
+                <StyledTh {...column.getHeaderProps()}>
+                  {column.render("Header")}
+                </StyledTh>
+              ))}
+            </StyledTr>
+          ))}
+        </StyledTHead>
+        <tbody {...getTableBodyProps()}>{renderRows()}</tbody>
+      </StyledTable>
+      {enablePagination && (
+        <FlexEnd style={{ marginTop: "15px" }}>
+          <PageCountWrapper>
+            Page size:
+            <Selector
+              activeValue={String(pageSize)}
+              options={[
+                {
+                  label: "10",
+                  value: "10",
+                },
+                {
+                  label: "20",
+                  value: "20",
+                },
+                {
+                  label: "50",
+                  value: "50",
+                },
+                {
+                  label: "100",
+                  value: "100",
+                },
+              ]}
+              setActiveValue={(val) => setPageSize(Number(val))}
+              width="70px"
+            ></Selector>
+          </PageCountWrapper>
+          <PaginationActionsWrapper>
+            <PaginationAction
+              disabled={!canPreviousPage}
+              onClick={previousPage}
+            >
+              {"<"}
+            </PaginationAction>
+            <PageCounter>
+              {pageIndex + 1} of {pageCount}
+            </PageCounter>
+            <PaginationAction disabled={!canNextPage} onClick={nextPage}>
+              {">"}
+            </PaginationAction>
+          </PaginationActionsWrapper>
+        </FlexEnd>
+      )}
+    </TableWrapper>
+  );
+};
+
+export default Table;
+
+const TableWrapper = styled.div`
+  padding-bottom: 20px;
+`;
+
+const FlexEnd = styled.div`
+  display: flex;
+  justify-content: flex-end;
+  align-items: center;
+  width: 100%;
+`;
+
+const PaginationActionsWrapper = styled.div``;
+
+const PageCountWrapper = styled.div`
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  min-width: 160px;
+  margin-right: 10px;
+`;
+
+const PaginationAction = styled.button`
+  border: none;
+  background: unset;
+  color: white;
+  padding: 10px;
+  cursor: pointer;
+  border-radius: 5px;
+  :hover {
+    background: #ffffff40;
+  }
+
+  :disabled {
+    color: #ffffff88;
+    cursor: unset;
+    :hover {
+      background: unset;
+    }
+  }
+`;
+
+const PageCounter = styled.span`
+  margin: 0 5px;
+`;
+
+type StyledTrProps = {
+  enablePointer?: boolean;
+  disableHover?: boolean;
+  selected?: boolean;
+};
+
+export const StyledTr = styled.tr`
+  line-height: 2.2em;
+  background: ${(props: StyledTrProps) => (props.selected ? "#ffffff11" : "")};
+  :hover {
+    background: ${(props: StyledTrProps) =>
+      props.disableHover ? "" : "#ffffff22"};
+  }
+  cursor: ${(props: StyledTrProps) =>
+    props.enablePointer ? "pointer" : "unset"};
+`;
+
+export const StyledTd = styled.td`
+  font-size: 13px;
+  color: #ffffff;
+  :first-child {
+    padding-left: 10px;
+  }
+  :last-child {
+    padding-right: 10px;
+  }
+  user-select: text;
+
+  ${(props: { align?: "center" | "left" }) => {
+    if (props.align) {
+      return `text-align:${props.align};`;
+    }
+  }}
+`;
+
+export const StyledTHead = styled.thead`
+  width: 100%;
+  border-top: 1px solid #aaaabb22;
+  border-bottom: 1px solid #aaaabb22;
+  position: sticky;
+`;
+
+export const StyledTh = styled.th`
+  text-align: left;
+  font-size: 13px;
+  font-weight: 500;
+  color: #aaaabb;
+  :first-child {
+    padding-left: 10px;
+  }
+  :last-child {
+    padding-right: 10px;
+  }
+`;
+
+export const StyledTable = styled.table`
+  width: 100%;
+  min-width: 500px;
+  border-collapse: collapse;
+`;
+
+const SearchInput = styled.input`
+  outline: none;
+  border: none;
+  font-size: 13px;
+  background: none;
+  width: 100%;
+  color: white;
+  padding: 0;
+  height: 21px;
+`;
+
+const SearchRow = styled.div`
+  display: flex;
+  width: 100%;
+  font-size: 13px;
+  color: #ffffff55;
+  border-radius: 4px;
+  user-select: none;
+  align-items: center;
+  padding: 7px 0px;
+  min-width: 300px;
+  max-width: min-content;
+  background: #ffffff11;
+
+  i {
+    width: 18px;
+    height: 18px;
+    margin-left: 12px;
+    margin-right: 12px;
+    font-size: 18px;
+  }
+`;
+
+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;
+  }
+`;

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

@@ -49,4 +49,5 @@ const StyledPlaceholder = styled.div<{
   border-radius: 5px;
   background: #26292e;
   border: 1px solid #494b4f;
+  padding-bottom: 60px;
 `;

+ 25 - 350
dashboard/src/components/Table.tsx

@@ -1,5 +1,5 @@
-import React, { useEffect } from "react";
-import styled from "styled-components";
+import Placeholder from "components/Placeholder";
+import React from "react";
 import {
   Column,
   Row,
@@ -7,96 +7,37 @@ import {
   usePagination,
   useTable,
 } from "react-table";
-import Loading from "components/Loading";
-import Selector from "./Selector";
-import loading from "assets/loading.gif";
-
-const GlobalFilter: React.FunctionComponent<any> = ({
-  setGlobalFilter,
-  onRefresh,
-  isRefreshing,
-}) => {
-  const [value, setValue] = React.useState("");
-  const onChange = (value: string) => {
-    setValue(value);
-    setGlobalFilter(value || undefined);
-  };
-
-  return (
-    <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>
-  );
-};
+import {
+  StyledTd,
+  StyledTable,
+  StyledTHead,
+  StyledTh,
+  StyledTBody,
+} from "../main/home/cluster-dashboard/expanded-chart/events/styles";
 
 export type TableProps = {
   columns: Column<any>[];
   data: any[];
   onRowClick?: (row: Row) => void;
-  isLoading: boolean;
-  disableGlobalFilter?: boolean;
-  disableHover?: boolean;
-  enablePagination?: boolean;
-  hasError?: boolean;
-  errorMessage?: string;
-  onRefresh?: () => void;
-  isRefreshing?: boolean;
+  placeholder?: string;
 };
 
-const MIN_PAGE_SIZE = 1;
-
 const Table: React.FC<TableProps> = ({
   columns: columnsData,
   data,
   onRowClick,
-  isLoading,
-  disableGlobalFilter = false,
-  disableHover,
-  enablePagination,
-  hasError,
-  errorMessage = "An unexpected error occurred, please try again.",
-  onRefresh,
-  isRefreshing = false,
+  placeholder,
 }) => {
+  if (!data || data.length == 0) {
+    return <Placeholder>{placeholder}</Placeholder>;
+  }
+
   const {
+    rows,
     getTableProps,
     getTableBodyProps,
-    page,
-    setGlobalFilter,
     prepareRow,
     headerGroups,
-    visibleColumns,
-
-    // Pagination options
-    canPreviousPage,
-    canNextPage,
-    pageOptions,
-    pageCount,
-    gotoPage,
-    nextPage,
-    previousPage,
-    setPageSize,
-    state: { pageIndex, pageSize },
   } = useTable(
     {
       columns: columnsData,
@@ -106,57 +47,19 @@ const Table: React.FC<TableProps> = ({
     usePagination
   );
 
-  useEffect(() => {
-    if (!enablePagination) {
-      setPageSize(data.length || MIN_PAGE_SIZE);
-    }
-  }, [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} height="150px">
-            <Loading />
-          </StyledTd>
-        </StyledTr>
-      );
-    }
-
-    if (!page.length) {
-      return (
-        <StyledTr disableHover={true} selected={false}>
-          <StyledTd colSpan={visibleColumns.length} align="center">
-            No data available
-          </StyledTd>
-        </StyledTr>
-      );
-    }
     return (
       <>
-        {page.map((row) => {
+        {rows.map((row: any) => {
           prepareRow(row);
 
           return (
-            <StyledTr
-              disableHover={disableHover}
+            <tr
               {...row.getRowProps()}
-              enablePointer={!!onRowClick}
               onClick={() => onRowClick && onRowClick(row)}
               selected={false}
             >
-              {/* TODO: This is actually broken, not sure why but we need the width to be properly setted, this is a temporary solution */}
-              {row.cells.map((cell) => {
+              {row.cells.map((cell: any) => {
                 return (
                   <StyledTd
                     {...cell.getCellProps()}
@@ -168,7 +71,7 @@ const Table: React.FC<TableProps> = ({
                   </StyledTd>
                 );
               })}
-            </StyledTr>
+            </tr>
           );
         })}
       </>
@@ -176,251 +79,23 @@ const Table: React.FC<TableProps> = ({
   };
 
   return (
-    <TableWrapper>
-      {!disableGlobalFilter && (
-        <GlobalFilter
-          setGlobalFilter={setGlobalFilter}
-          onRefresh={onRefresh}
-          isRefreshing={isRefreshing}
-        />
-      )}
+    <>
       <StyledTable {...getTableProps()}>
         <StyledTHead>
           {headerGroups.map((headerGroup) => (
-            <StyledTr
-              {...headerGroup.getHeaderGroupProps()}
-              disableHover={true}
-            >
+            <tr {...headerGroup.getHeaderGroupProps()}>
               {headerGroup.headers.map((column) => (
                 <StyledTh {...column.getHeaderProps()}>
                   {column.render("Header")}
                 </StyledTh>
               ))}
-            </StyledTr>
+            </tr>
           ))}
         </StyledTHead>
-        <tbody {...getTableBodyProps()}>{renderRows()}</tbody>
+        <StyledTBody {...getTableBodyProps()}>{renderRows()}</StyledTBody>
       </StyledTable>
-      {enablePagination && (
-        <FlexEnd style={{ marginTop: "15px" }}>
-          <PageCountWrapper>
-            Page size:
-            <Selector
-              activeValue={String(pageSize)}
-              options={[
-                {
-                  label: "10",
-                  value: "10",
-                },
-                {
-                  label: "20",
-                  value: "20",
-                },
-                {
-                  label: "50",
-                  value: "50",
-                },
-                {
-                  label: "100",
-                  value: "100",
-                },
-              ]}
-              setActiveValue={(val) => setPageSize(Number(val))}
-              width="70px"
-            ></Selector>
-          </PageCountWrapper>
-          <PaginationActionsWrapper>
-            <PaginationAction
-              disabled={!canPreviousPage}
-              onClick={previousPage}
-            >
-              {"<"}
-            </PaginationAction>
-            <PageCounter>
-              {pageIndex + 1} of {pageCount}
-            </PageCounter>
-            <PaginationAction disabled={!canNextPage} onClick={nextPage}>
-              {">"}
-            </PaginationAction>
-          </PaginationActionsWrapper>
-        </FlexEnd>
-      )}
-    </TableWrapper>
+    </>
   );
 };
 
 export default Table;
-
-const TableWrapper = styled.div`
-  padding-bottom: 20px;
-`;
-
-const FlexEnd = styled.div`
-  display: flex;
-  justify-content: flex-end;
-  align-items: center;
-  width: 100%;
-`;
-
-const PaginationActionsWrapper = styled.div``;
-
-const PageCountWrapper = styled.div`
-  display: flex;
-  align-items: center;
-  justify-content: space-between;
-  min-width: 160px;
-  margin-right: 10px;
-`;
-
-const PaginationAction = styled.button`
-  border: none;
-  background: unset;
-  color: white;
-  padding: 10px;
-  cursor: pointer;
-  border-radius: 5px;
-  :hover {
-    background: #ffffff40;
-  }
-
-  :disabled {
-    color: #ffffff88;
-    cursor: unset;
-    :hover {
-      background: unset;
-    }
-  }
-`;
-
-const PageCounter = styled.span`
-  margin: 0 5px;
-`;
-
-type StyledTrProps = {
-  enablePointer?: boolean;
-  disableHover?: boolean;
-  selected?: boolean;
-};
-
-export const StyledTr = styled.tr`
-  line-height: 2.2em;
-  background: ${(props: StyledTrProps) => (props.selected ? "#ffffff11" : "")};
-  :hover {
-    background: ${(props: StyledTrProps) =>
-      props.disableHover ? "" : "#ffffff22"};
-  }
-  cursor: ${(props: StyledTrProps) =>
-    props.enablePointer ? "pointer" : "unset"};
-`;
-
-export const StyledTd = styled.td`
-  font-size: 13px;
-  color: #ffffff;
-  :first-child {
-    padding-left: 10px;
-  }
-  :last-child {
-    padding-right: 10px;
-  }
-  user-select: text;
-
-  ${(props: { align?: "center" | "left" }) => {
-    if (props.align) {
-      return `text-align:${props.align};`;
-    }
-  }}
-`;
-
-export const StyledTHead = styled.thead`
-  width: 100%;
-  border-top: 1px solid #aaaabb22;
-  border-bottom: 1px solid #aaaabb22;
-  position: sticky;
-`;
-
-export const StyledTh = styled.th`
-  text-align: left;
-  font-size: 13px;
-  font-weight: 500;
-  color: #aaaabb;
-  :first-child {
-    padding-left: 10px;
-  }
-  :last-child {
-    padding-right: 10px;
-  }
-`;
-
-export const StyledTable = styled.table`
-  width: 100%;
-  min-width: 500px;
-  border-collapse: collapse;
-`;
-
-const SearchInput = styled.input`
-  outline: none;
-  border: none;
-  font-size: 13px;
-  background: none;
-  width: 100%;
-  color: white;
-  padding: 0;
-  height: 21px;
-`;
-
-const SearchRow = styled.div`
-  display: flex;
-  width: 100%;
-  font-size: 13px;
-  color: #ffffff55;
-  border-radius: 4px;
-  user-select: none;
-  align-items: center;
-  padding: 7px 0px;
-  min-width: 300px;
-  max-width: min-content;
-  background: #ffffff11;
-
-  i {
-    width: 18px;
-    height: 18px;
-    margin-left: 12px;
-    margin-right: 12px;
-    font-size: 18px;
-  }
-`;
-
-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;
-  }
-`;

+ 8 - 14
dashboard/src/components/date-time-picker/DateTimePicker.tsx

@@ -4,6 +4,7 @@ import DatePicker from "react-datepicker";
 import time from "assets/time.svg";
 
 import styled from "styled-components";
+import "./react-datepicker.css";
 
 type Props = {
   startDate: any;
@@ -12,12 +13,12 @@ type Props = {
 
 const DateTimePicker: React.FC<Props> = ({ startDate, setStartDate }) => {
   return (
-    <DateTimePickerWrapper>
-      <TimeIcon src={time} />
-      <link
-        rel="stylesheet"
-        href="https://cdnjs.cloudflare.com/ajax/libs/react-datepicker/2.14.1/react-datepicker.min.css"
-      />
+    <DateTimePickerWrapper
+      onClick={(e) => {
+        e.stopPropagation();
+        e.nativeEvent.stopImmediatePropagation();
+      }}
+    >
       <Bar />
       <StyledDatePicker
         selected={startDate}
@@ -53,13 +54,7 @@ const DateTimePickerWrapper = styled.div`
   align-items: center;
   justify-content: center;
   padding-right: 42px;
-  margin-right: 10px;
-  padding-left: 8px;
-  height: 30px;
-  border-radius: 5px;
-  border: 1px solid #494b4f;
-  height: 30px;
-  background: #26292e;
+  margin-left: 2px;
 `;
 
 const StyledDatePicker = styled(DatePicker)`
@@ -67,7 +62,6 @@ const StyledDatePicker = styled(DatePicker)`
   width: calc(100% + 42px);
   position: relative;
   border: none;
-  margin-bottom: 3px;
   outline-width: 0;
   background: transparent;
   text-align: center;

+ 34 - 148
dashboard/src/components/date-time-picker/react-datepicker.css

@@ -1,3 +1,7 @@
+.react-datepicker__triangle {
+  display: none;
+}
+
 .react-datepicker-popper[data-placement^="bottom"] .react-datepicker__triangle,
 .react-datepicker-popper[data-placement^="top"] .react-datepicker__triangle,
 .react-datepicker__year-read-view--down-arrow,
@@ -6,7 +10,6 @@
   margin-left: -8px;
   position: absolute;
 }
-
 .react-datepicker-popper[data-placement^="bottom"] .react-datepicker__triangle,
 .react-datepicker-popper[data-placement^="top"] .react-datepicker__triangle,
 .react-datepicker__year-read-view--down-arrow,
@@ -25,7 +28,6 @@
   height: 0;
   width: 1px;
 }
-
 .react-datepicker-popper[data-placement^="bottom"]
   .react-datepicker__triangle::before,
 .react-datepicker-popper[data-placement^="top"]
@@ -39,25 +41,21 @@
   left: -8px;
   border-bottom-color: #aeaeae;
 }
-
 .react-datepicker-popper[data-placement^="bottom"] .react-datepicker__triangle {
   top: 0;
   margin-top: -8px;
 }
-
 .react-datepicker-popper[data-placement^="bottom"] .react-datepicker__triangle,
 .react-datepicker-popper[data-placement^="bottom"]
   .react-datepicker__triangle::before {
   border-top: none;
-  border-bottom-color: #f0f0f0;
+  border-bottom-color: #26292e;
 }
-
 .react-datepicker-popper[data-placement^="bottom"]
   .react-datepicker__triangle::before {
   top: -1px;
   border-bottom-color: #aeaeae;
 }
-
 .react-datepicker-popper[data-placement^="top"] .react-datepicker__triangle,
 .react-datepicker__year-read-view--down-arrow,
 .react-datepicker__month-read-view--down-arrow,
@@ -65,7 +63,6 @@
   bottom: 0;
   margin-bottom: -8px;
 }
-
 .react-datepicker-popper[data-placement^="top"] .react-datepicker__triangle,
 .react-datepicker__year-read-view--down-arrow,
 .react-datepicker__month-read-view--down-arrow,
@@ -78,7 +75,6 @@
   border-bottom: none;
   border-top-color: #fff;
 }
-
 .react-datepicker-popper[data-placement^="top"]
   .react-datepicker__triangle::before,
 .react-datepicker__year-read-view--down-arrow::before,
@@ -87,98 +83,80 @@
   bottom: -1px;
   border-top-color: #aeaeae;
 }
-
 .react-datepicker-wrapper {
   display: inline-block;
   padding: 0;
   border: 0;
 }
-
 .react-datepicker {
-  font-family: "Helvetica Neue", Helvetica, Arial, sans-serif;
-  font-size: 0.8rem;
-  background-color: #fff;
-  color: #000;
+  font-family: "Work Sans", Arial, sans-serif;
+  font-size: 0.75rem;
+  background-color: #26292e;
+  color: white;
   border: 1px solid #aeaeae;
   border-radius: 0.3rem;
   display: inline-block;
   position: relative;
 }
-
 .react-datepicker--time-only .react-datepicker__triangle {
   left: 35px;
 }
-
 .react-datepicker--time-only .react-datepicker__time-container {
   border-left: 0;
 }
-
 .react-datepicker--time-only .react-datepicker__time {
   border-radius: 0.3rem;
 }
-
 .react-datepicker--time-only .react-datepicker__time-box {
   border-radius: 0.3rem;
 }
-
 .react-datepicker__triangle {
   position: absolute;
   left: 50px;
 }
-
 .react-datepicker-popper {
   z-index: 1;
 }
-
 .react-datepicker-popper[data-placement^="bottom"] {
   margin-top: 10px;
 }
-
 .react-datepicker-popper[data-placement="bottom-end"]
   .react-datepicker__triangle,
 .react-datepicker-popper[data-placement="top-end"] .react-datepicker__triangle {
   left: auto;
   right: 50px;
 }
-
 .react-datepicker-popper[data-placement^="top"] {
   margin-bottom: 10px;
 }
-
 .react-datepicker-popper[data-placement^="right"] {
   margin-left: 8px;
 }
-
 .react-datepicker-popper[data-placement^="right"] .react-datepicker__triangle {
   left: auto;
   right: 42px;
 }
-
 .react-datepicker-popper[data-placement^="left"] {
   margin-right: 8px;
 }
-
 .react-datepicker-popper[data-placement^="left"] .react-datepicker__triangle {
   left: 42px;
   right: auto;
 }
-
 .react-datepicker__header {
   text-align: center;
-  background-color: #f0f0f0;
+  background-color: #26292e;
   border-bottom: 1px solid #aeaeae;
   border-top-left-radius: 0.3rem;
   border-top-right-radius: 0.3rem;
   padding-top: 8px;
   position: relative;
 }
-
 .react-datepicker__header--time {
   padding-bottom: 8px;
   padding-left: 5px;
   padding-right: 5px;
 }
-
 .react-datepicker__year-dropdown-container--select,
 .react-datepicker__month-dropdown-container--select,
 .react-datepicker__month-year-dropdown-container--select,
@@ -188,22 +166,19 @@
   display: inline-block;
   margin: 0 2px;
 }
-
 .react-datepicker__current-month,
 .react-datepicker-time__header,
 .react-datepicker-year-header {
   margin-top: 0;
-  color: #000;
+  color: white;
   font-weight: bold;
-  font-size: 0.944rem;
+  font-size: 0.85rem;
 }
-
 .react-datepicker-time__header {
   text-overflow: ellipsis;
   white-space: nowrap;
   overflow: hidden;
 }
-
 .react-datepicker__navigation {
   background: none;
   line-height: 1.7rem;
@@ -220,41 +195,33 @@
   text-indent: -999em;
   overflow: hidden;
 }
-
 .react-datepicker__navigation--previous {
   left: 10px;
   border-right-color: #ccc;
 }
-
 .react-datepicker__navigation--previous:hover {
   border-right-color: #b3b3b3;
 }
-
 .react-datepicker__navigation--previous--disabled,
 .react-datepicker__navigation--previous--disabled:hover {
   border-right-color: #e6e6e6;
   cursor: default;
 }
-
 .react-datepicker__navigation--next {
   right: 10px;
   border-left-color: #ccc;
 }
-
 .react-datepicker__navigation--next--with-time:not(.react-datepicker__navigation--next--with-today-button) {
-  right: 80px;
+  right: 94px;
 }
-
 .react-datepicker__navigation--next:hover {
   border-left-color: #b3b3b3;
 }
-
 .react-datepicker__navigation--next--disabled,
 .react-datepicker__navigation--next--disabled:hover {
   border-left-color: #e6e6e6;
   cursor: default;
 }
-
 .react-datepicker__navigation--years {
   position: relative;
   top: 0;
@@ -262,41 +229,33 @@
   margin-left: auto;
   margin-right: auto;
 }
-
 .react-datepicker__navigation--years-previous {
   top: 4px;
   border-top-color: #ccc;
 }
-
 .react-datepicker__navigation--years-previous:hover {
   border-top-color: #b3b3b3;
 }
-
 .react-datepicker__navigation--years-upcoming {
   top: -4px;
   border-bottom-color: #ccc;
 }
-
 .react-datepicker__navigation--years-upcoming:hover {
   border-bottom-color: #b3b3b3;
 }
-
 .react-datepicker__month-container {
   float: left;
 }
-
 .react-datepicker__month {
   margin: 0.4rem;
   text-align: center;
 }
-
 .react-datepicker__month .react-datepicker__month-text,
 .react-datepicker__month .react-datepicker__quarter-text {
   display: inline-block;
   width: 4rem;
   margin: 2px;
 }
-
 .react-datepicker__input-time-container {
   clear: both;
   width: 100%;
@@ -304,30 +263,25 @@
   margin: 5px 0 10px 15px;
   text-align: left;
 }
-
 .react-datepicker__input-time-container .react-datepicker-time__caption {
   display: inline-block;
 }
-
 .react-datepicker__input-time-container
   .react-datepicker-time__input-container {
   display: inline-block;
 }
-
 .react-datepicker__input-time-container
   .react-datepicker-time__input-container
   .react-datepicker-time__input {
   display: inline-block;
   margin-left: 10px;
 }
-
 .react-datepicker__input-time-container
   .react-datepicker-time__input-container
   .react-datepicker-time__input
   input {
   width: 85px;
 }
-
 .react-datepicker__input-time-container
   .react-datepicker-time__input-container
   .react-datepicker-time__input
@@ -339,27 +293,23 @@
   -webkit-appearance: none;
   margin: 0;
 }
-
 .react-datepicker__input-time-container
   .react-datepicker-time__input-container
   .react-datepicker-time__input
   input[type="time"] {
   -moz-appearance: textfield;
 }
-
 .react-datepicker__input-time-container
   .react-datepicker-time__input-container
   .react-datepicker-time__delimiter {
   margin-left: 5px;
   display: inline-block;
 }
-
 .react-datepicker__time-container {
   float: right;
   border-left: 1px solid #aeaeae;
   width: 85px;
 }
-
 .react-datepicker__time-container--with-today-button {
   display: inline;
   border: 1px solid #aeaeae;
@@ -368,12 +318,10 @@
   right: -72px;
   top: 0;
 }
-
 .react-datepicker__time-container .react-datepicker__time {
   position: relative;
-  background: white;
+  background: #26292e;
 }
-
 .react-datepicker__time-container
   .react-datepicker__time
   .react-datepicker__time-box {
@@ -382,7 +330,6 @@
   margin: 0 auto;
   text-align: center;
 }
-
 .react-datepicker__time-container
   .react-datepicker__time
   .react-datepicker__time-box
@@ -396,44 +343,39 @@
   width: 100%;
   box-sizing: content-box;
 }
-
 .react-datepicker__time-container
   .react-datepicker__time
   .react-datepicker__time-box
   ul.react-datepicker__time-list
   li.react-datepicker__time-list-item {
   height: 30px;
-  padding: 5px 10px;
+  padding: 7px 10px;
   white-space: nowrap;
 }
-
 .react-datepicker__time-container
   .react-datepicker__time
   .react-datepicker__time-box
   ul.react-datepicker__time-list
   li.react-datepicker__time-list-item:hover {
   cursor: pointer;
-  background-color: #f0f0f0;
+  background-color: #26292e;
 }
-
 .react-datepicker__time-container
   .react-datepicker__time
   .react-datepicker__time-box
   ul.react-datepicker__time-list
   li.react-datepicker__time-list-item--selected {
-  background-color: #216ba5;
+  background-color: #949eff;
   color: white;
   font-weight: bold;
 }
-
 .react-datepicker__time-container
   .react-datepicker__time
   .react-datepicker__time-box
   ul.react-datepicker__time-list
   li.react-datepicker__time-list-item--selected:hover {
-  background-color: #216ba5;
+  background-color: #949eff;
 }
-
 .react-datepicker__time-container
   .react-datepicker__time
   .react-datepicker__time-box
@@ -441,7 +383,6 @@
   li.react-datepicker__time-list-item--disabled {
   color: #ccc;
 }
-
 .react-datepicker__time-container
   .react-datepicker__time
   .react-datepicker__time-box
@@ -450,7 +391,6 @@
   cursor: default;
   background-color: transparent;
 }
-
 .react-datepicker__week-number {
   color: #ccc;
   display: inline-block;
@@ -459,32 +399,27 @@
   text-align: center;
   margin: 0.166rem;
 }
-
 .react-datepicker__week-number.react-datepicker__week-number--clickable {
   cursor: pointer;
 }
-
 .react-datepicker__week-number.react-datepicker__week-number--clickable:hover {
   border-radius: 0.3rem;
-  background-color: #f0f0f0;
+  background-color: #26292e;
 }
-
 .react-datepicker__day-names,
 .react-datepicker__week {
   white-space: nowrap;
 }
-
 .react-datepicker__day-name,
 .react-datepicker__day,
 .react-datepicker__time-name {
-  color: #000;
+  color: white;
   display: inline-block;
   width: 1.7rem;
   line-height: 1.7rem;
   text-align: center;
   margin: 0.166rem;
 }
-
 .react-datepicker__month--selected,
 .react-datepicker__month--in-selecting-range,
 .react-datepicker__month--in-range,
@@ -492,50 +427,43 @@
 .react-datepicker__quarter--in-selecting-range,
 .react-datepicker__quarter--in-range {
   border-radius: 0.3rem;
-  background-color: #216ba5;
+  background-color: #949eff;
   color: #fff;
 }
-
 .react-datepicker__month--selected:hover,
 .react-datepicker__month--in-selecting-range:hover,
 .react-datepicker__month--in-range:hover,
 .react-datepicker__quarter--selected:hover,
 .react-datepicker__quarter--in-selecting-range:hover,
 .react-datepicker__quarter--in-range:hover {
-  background-color: #1d5d90;
+  background-color: #949eff;
 }
-
 .react-datepicker__month--disabled,
 .react-datepicker__quarter--disabled {
   color: #ccc;
   pointer-events: none;
 }
-
 .react-datepicker__month--disabled:hover,
 .react-datepicker__quarter--disabled:hover {
   cursor: default;
   background-color: transparent;
 }
-
 .react-datepicker__day,
 .react-datepicker__month-text,
 .react-datepicker__quarter-text {
   cursor: pointer;
 }
-
 .react-datepicker__day:hover,
 .react-datepicker__month-text:hover,
 .react-datepicker__quarter-text:hover {
   border-radius: 0.3rem;
-  background-color: #f0f0f0;
+  background-color: #26292e;
 }
-
 .react-datepicker__day--today,
 .react-datepicker__month-text--today,
 .react-datepicker__quarter-text--today {
   font-weight: bold;
 }
-
 .react-datepicker__day--highlighted,
 .react-datepicker__month-text--highlighted,
 .react-datepicker__quarter-text--highlighted {
@@ -543,25 +471,21 @@
   background-color: #3dcc4a;
   color: #fff;
 }
-
 .react-datepicker__day--highlighted:hover,
 .react-datepicker__month-text--highlighted:hover,
 .react-datepicker__quarter-text--highlighted:hover {
   background-color: #32be3f;
 }
-
 .react-datepicker__day--highlighted-custom-1,
 .react-datepicker__month-text--highlighted-custom-1,
 .react-datepicker__quarter-text--highlighted-custom-1 {
   color: magenta;
 }
-
 .react-datepicker__day--highlighted-custom-2,
 .react-datepicker__month-text--highlighted-custom-2,
 .react-datepicker__quarter-text--highlighted-custom-2 {
   color: green;
 }
-
 .react-datepicker__day--selected,
 .react-datepicker__day--in-selecting-range,
 .react-datepicker__day--in-range,
@@ -572,10 +496,9 @@
 .react-datepicker__quarter-text--in-selecting-range,
 .react-datepicker__quarter-text--in-range {
   border-radius: 0.3rem;
-  background-color: #216ba5;
+  background-color: #949eff;
   color: #fff;
 }
-
 .react-datepicker__day--selected:hover,
 .react-datepicker__day--in-selecting-range:hover,
 .react-datepicker__day--in-range:hover,
@@ -585,51 +508,44 @@
 .react-datepicker__quarter-text--selected:hover,
 .react-datepicker__quarter-text--in-selecting-range:hover,
 .react-datepicker__quarter-text--in-range:hover {
-  background-color: #1d5d90;
+  background-color: #949eff;
 }
-
 .react-datepicker__day--keyboard-selected,
 .react-datepicker__month-text--keyboard-selected,
 .react-datepicker__quarter-text--keyboard-selected {
   border-radius: 0.3rem;
-  background-color: #2a87d0;
+  background-color: #949eff;
   color: #fff;
 }
-
 .react-datepicker__day--keyboard-selected:hover,
 .react-datepicker__month-text--keyboard-selected:hover,
 .react-datepicker__quarter-text--keyboard-selected:hover {
-  background-color: #1d5d90;
+  background-color: #949eff;
 }
-
 .react-datepicker__day--in-selecting-range,
 .react-datepicker__month-text--in-selecting-range,
 .react-datepicker__quarter-text--in-selecting-range {
   background-color: rgba(33, 107, 165, 0.5);
 }
-
 .react-datepicker__month--selecting-range .react-datepicker__day--in-range,
 .react-datepicker__month--selecting-range
   .react-datepicker__month-text--in-range,
 .react-datepicker__month--selecting-range
   .react-datepicker__quarter-text--in-range {
-  background-color: #f0f0f0;
-  color: #000;
+  background-color: #26292e;
+  color: white;
 }
-
 .react-datepicker__day--disabled,
 .react-datepicker__month-text--disabled,
 .react-datepicker__quarter-text--disabled {
   cursor: default;
   color: #ccc;
 }
-
 .react-datepicker__day--disabled:hover,
 .react-datepicker__month-text--disabled:hover,
 .react-datepicker__quarter-text--disabled:hover {
   background-color: transparent;
 }
-
 .react-datepicker__month-text.react-datepicker__month--selected:hover,
 .react-datepicker__month-text.react-datepicker__month--in-range:hover,
 .react-datepicker__month-text.react-datepicker__quarter--selected:hover,
@@ -638,33 +554,28 @@
 .react-datepicker__quarter-text.react-datepicker__month--in-range:hover,
 .react-datepicker__quarter-text.react-datepicker__quarter--selected:hover,
 .react-datepicker__quarter-text.react-datepicker__quarter--in-range:hover {
-  background-color: #216ba5;
+  background-color: #949eff;
 }
-
 .react-datepicker__month-text:hover,
 .react-datepicker__quarter-text:hover {
-  background-color: #f0f0f0;
+  background-color: #949eff;
 }
-
 .react-datepicker__input-container {
   position: relative;
   display: inline-block;
   width: 100%;
 }
-
 .react-datepicker__year-read-view,
 .react-datepicker__month-read-view,
 .react-datepicker__month-year-read-view {
   border: 1px solid transparent;
   border-radius: 0.3rem;
 }
-
 .react-datepicker__year-read-view:hover,
 .react-datepicker__month-read-view:hover,
 .react-datepicker__month-year-read-view:hover {
   cursor: pointer;
 }
-
 .react-datepicker__year-read-view:hover
   .react-datepicker__year-read-view--down-arrow,
 .react-datepicker__year-read-view:hover
@@ -679,7 +590,6 @@
   .react-datepicker__month-read-view--down-arrow {
   border-top-color: #b3b3b3;
 }
-
 .react-datepicker__year-read-view--down-arrow,
 .react-datepicker__month-read-view--down-arrow,
 .react-datepicker__month-year-read-view--down-arrow {
@@ -690,11 +600,10 @@
   position: relative;
   border-width: 0.45rem;
 }
-
 .react-datepicker__year-dropdown,
 .react-datepicker__month-dropdown,
 .react-datepicker__month-year-dropdown {
-  background-color: #f0f0f0;
+  background-color: #26292e;
   position: absolute;
   width: 50%;
   left: 25%;
@@ -704,20 +613,17 @@
   border-radius: 0.3rem;
   border: 1px solid #aeaeae;
 }
-
 .react-datepicker__year-dropdown:hover,
 .react-datepicker__month-dropdown:hover,
 .react-datepicker__month-year-dropdown:hover {
   cursor: pointer;
 }
-
 .react-datepicker__year-dropdown--scrollable,
 .react-datepicker__month-dropdown--scrollable,
 .react-datepicker__month-year-dropdown--scrollable {
   height: 150px;
   overflow-y: scroll;
 }
-
 .react-datepicker__year-option,
 .react-datepicker__month-option,
 .react-datepicker__month-year-option {
@@ -727,14 +633,12 @@
   margin-left: auto;
   margin-right: auto;
 }
-
 .react-datepicker__year-option:first-of-type,
 .react-datepicker__month-option:first-of-type,
 .react-datepicker__month-year-option:first-of-type {
   border-top-left-radius: 0.3rem;
   border-top-right-radius: 0.3rem;
 }
-
 .react-datepicker__year-option:last-of-type,
 .react-datepicker__month-option:last-of-type,
 .react-datepicker__month-year-option:last-of-type {
@@ -745,13 +649,11 @@
   border-bottom-left-radius: 0.3rem;
   border-bottom-right-radius: 0.3rem;
 }
-
 .react-datepicker__year-option:hover,
 .react-datepicker__month-option:hover,
 .react-datepicker__month-year-option:hover {
   background-color: #ccc;
 }
-
 .react-datepicker__year-option:hover
   .react-datepicker__navigation--years-upcoming,
 .react-datepicker__month-option:hover
@@ -760,7 +662,6 @@
   .react-datepicker__navigation--years-upcoming {
   border-bottom-color: #b3b3b3;
 }
-
 .react-datepicker__year-option:hover
   .react-datepicker__navigation--years-previous,
 .react-datepicker__month-option:hover
@@ -769,14 +670,12 @@
   .react-datepicker__navigation--years-previous {
   border-top-color: #b3b3b3;
 }
-
 .react-datepicker__year-option--selected,
 .react-datepicker__month-option--selected,
 .react-datepicker__month-year-option--selected {
   position: absolute;
   left: 15px;
 }
-
 .react-datepicker__close-icon {
   cursor: pointer;
   background-color: transparent;
@@ -790,10 +689,9 @@
   display: table-cell;
   vertical-align: middle;
 }
-
 .react-datepicker__close-icon::after {
   cursor: pointer;
-  background-color: #216ba5;
+  background-color: #949eff;
   color: #fff;
   border-radius: 50%;
   height: 16px;
@@ -806,9 +704,8 @@
   vertical-align: middle;
   content: "\00d7";
 }
-
 .react-datepicker__today-button {
-  background: #f0f0f0;
+  background: #26292e;
   border-top: 1px solid #aeaeae;
   cursor: pointer;
   text-align: center;
@@ -816,7 +713,6 @@
   padding: 5px 0;
   clear: left;
 }
-
 .react-datepicker__portal {
   position: fixed;
   width: 100vw;
@@ -829,14 +725,12 @@
   display: flex;
   z-index: 2147483647;
 }
-
 .react-datepicker__portal .react-datepicker__day-name,
 .react-datepicker__portal .react-datepicker__day,
 .react-datepicker__portal .react-datepicker__time-name {
   width: 3rem;
   line-height: 3rem;
 }
-
 @media (max-width: 400px), (max-height: 550px) {
   .react-datepicker__portal .react-datepicker__day-name,
   .react-datepicker__portal .react-datepicker__day,
@@ -845,39 +739,31 @@
     line-height: 2rem;
   }
 }
-
 .react-datepicker__portal .react-datepicker__current-month,
 .react-datepicker__portal .react-datepicker-time__header {
   font-size: 1.44rem;
 }
-
 .react-datepicker__portal .react-datepicker__navigation {
   border: 0.81rem solid transparent;
 }
-
 .react-datepicker__portal .react-datepicker__navigation--previous {
   border-right-color: #ccc;
 }
-
 .react-datepicker__portal .react-datepicker__navigation--previous:hover {
   border-right-color: #b3b3b3;
 }
-
 .react-datepicker__portal .react-datepicker__navigation--previous--disabled,
 .react-datepicker__portal
   .react-datepicker__navigation--previous--disabled:hover {
   border-right-color: #e6e6e6;
   cursor: default;
 }
-
 .react-datepicker__portal .react-datepicker__navigation--next {
   border-left-color: #ccc;
 }
-
 .react-datepicker__portal .react-datepicker__navigation--next:hover {
   border-left-color: #b3b3b3;
 }
-
 .react-datepicker__portal .react-datepicker__navigation--next--disabled,
 .react-datepicker__portal .react-datepicker__navigation--next--disabled:hover {
   border-left-color: #e6e6e6;

+ 16 - 2
dashboard/src/components/porter-form/PorterFormWrapper.tsx

@@ -1,8 +1,9 @@
-import React, { useState } from "react";
+import React, { useEffect, useState } from "react";
 
 import PorterForm from "./PorterForm";
 import { InjectedProps, PorterFormData } from "./types";
 import { PorterFormContextProvider } from "./PorterFormContextProvider";
+import _ from "lodash";
 
 type PropsType = {
   formData: any;
@@ -24,6 +25,8 @@ type PropsType = {
   redirectTabAfterSave?: string;
   includeMetadata?: boolean;
   injectedProps?: InjectedProps;
+  overrideCurrentTab?: string;
+  onTabChange?: (newTab: string) => void;
 };
 
 const PorterFormWrapper: React.FC<PropsType> = ({
@@ -46,6 +49,8 @@ const PorterFormWrapper: React.FC<PropsType> = ({
   redirectTabAfterSave,
   includeMetadata,
   injectedProps,
+  overrideCurrentTab,
+  onTabChange = _.noop,
 }) => {
   const hashCode = (s: string) => {
     return s?.split("").reduce(function (a, b) {
@@ -75,6 +80,12 @@ const PorterFormWrapper: React.FC<PropsType> = ({
   // Lifted into PorterFormWrapper to allow tab to be remembered on re-render (e.g., on revision select)
   const [currentTab, setCurrentTab] = useState(getInitialTab());
 
+  useEffect(() => {
+    if (overrideCurrentTab) {
+      setCurrentTab(overrideCurrentTab);
+    }
+  }, [overrideCurrentTab]);
+
   return (
     <React.Fragment key={hashCode(JSON.stringify(formData))}>
       <PorterFormContextProvider
@@ -97,7 +108,10 @@ const PorterFormWrapper: React.FC<PropsType> = ({
           color={color}
           saveValuesStatus={saveValuesStatus}
           currentTab={currentTab}
-          setCurrentTab={setCurrentTab}
+          setCurrentTab={(newTab) => {
+            setCurrentTab(newTab);
+            onTabChange(newTab);
+          }}
           isLaunch={isLaunch}
           hideSpacer={hideBottomSpacer}
           redirectTabAfterSave={redirectTabAfterSave}

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

@@ -1,12 +1,13 @@
 import DynamicLink from "components/DynamicLink";
 import Loading from "components/Loading";
-import Table from "components/Table";
+import Table from "components/OldTable";
 import React, { useContext, useEffect, useMemo, useRef, useState } from "react";
 import { CellProps, Column, Row } from "react-table";
 import api from "shared/api";
 import { Context } from "shared/Context";
 import { NewWebsocketOptions, useWebsockets } from "shared/hooks/useWebsockets";
 import { useRouting } from "shared/routing";
+import { relativeDate, timeFrom } from "shared/string_utils";
 import styled from "styled-components";
 
 type Props = {
@@ -15,26 +16,6 @@ type Props = {
   sortType: "Newest" | "Oldest" | "Alphabetical";
 };
 
-export const dateFormatter = (date: string | number) => {
-  if (!date) {
-    return "N/A";
-  }
-
-  // @ts-ignore
-  const rtf = new Intl.RelativeTimeFormat("en", {
-    localeMatcher: "best fit", // other values: "lookup"
-    numeric: "auto", // other values: "auto"
-    style: "long", // other values: "short" or "narrow"
-  });
-
-  const time = timeFrom(date);
-  if (!time) {
-    return "N/A";
-  }
-
-  return rtf.format(-time.time, time.unitOfTime);
-};
-
 const runnedFor = (start: string | number, end?: string | number) => {
   const duration = timeFrom(start, end);
 
@@ -46,65 +27,6 @@ const runnedFor = (start: string | number, end?: string | number) => {
   return `${duration.time} ${unit}`;
 };
 
-function timeFrom(time: string | number, secondTime?: string | number) {
-  // Get timestamps
-  let unixTime = new Date(time).getTime();
-  if (!unixTime) return;
-
-  let now = new Date().getTime();
-
-  if (secondTime) {
-    now = new Date(secondTime).getTime();
-  }
-
-  // Calculate difference
-  let difference = unixTime / 1000 - now / 1000;
-
-  // Setup return object
-  let tfn: any = {};
-
-  // Check if time is in the past, present, or future
-  tfn.when = "now";
-  if (difference > 0) {
-    tfn.when = "future";
-  } else if (difference < -1) {
-    tfn.when = "past";
-  }
-
-  // Convert difference to absolute
-  difference = Math.abs(difference);
-
-  // Calculate time unit
-  if (difference / (60 * 60 * 24 * 365) > 1) {
-    // Years
-    tfn.unitOfTime = "years";
-    tfn.time = Math.floor(difference / (60 * 60 * 24 * 365));
-  } else if (difference / (60 * 60 * 24 * 45) > 1) {
-    // Months
-    tfn.unitOfTime = "months";
-    tfn.time = Math.floor(difference / (60 * 60 * 24 * 45));
-  } else if (difference / (60 * 60 * 24) > 1) {
-    // Days
-    tfn.unitOfTime = "days";
-    tfn.time = Math.floor(difference / (60 * 60 * 24));
-  } else if (difference / (60 * 60) > 1) {
-    // Hours
-    tfn.unitOfTime = "hours";
-    tfn.time = Math.floor(difference / (60 * 60));
-  } else if (difference / 60 > 1) {
-    // Minutes
-    tfn.unitOfTime = "minutes";
-    tfn.time = Math.floor(difference / 60);
-  } else {
-    // Seconds
-    tfn.unitOfTime = "seconds";
-    tfn.time = Math.floor(difference);
-  }
-
-  // Return time from now data
-  return tfn;
-}
-
 const JobRunTable: React.FC<Props> = ({
   lastRunStatus,
   namespace,
@@ -199,7 +121,7 @@ const JobRunTable: React.FC<Props> = ({
       },
       {
         Header: "Run at",
-        accessor: (originalRow) => dateFormatter(originalRow.status.startTime),
+        accessor: (originalRow) => relativeDate(originalRow.status.startTime),
       },
       {
         Header: "Run for",

+ 75 - 16
dashboard/src/main/home/cluster-dashboard/dashboard/ClusterSettings.tsx

@@ -1,18 +1,26 @@
-import React, { useContext, useState } from "react";
+import React, { useContext, useEffect, useState } from "react";
 import styled from "styled-components";
 import Heading from "components/form-components/Heading";
 import Helper from "components/form-components/Helper";
 import InputRow from "components/form-components/InputRow";
 import { Context } from "shared/Context";
 import api from "shared/api";
+import CheckboxRow from "components/form-components/CheckboxRow";
+import Loading from "components/Loading";
 
 const ClusterSettings: React.FC = () => {
-  const context = useContext(Context);
+  const {
+    currentProject,
+    currentCluster,
+    setCurrentCluster,
+    setCurrentModal,
+    capabilities,
+  } = useContext(Context);
   const [newClusterName, setNewClusterName] = useState<string>(
-    context.currentCluster.name
+    currentCluster.name
   );
   const [newAWSClusterID, setNewAWSClusterID] = useState<string>(
-    context.currentCluster.aws_cluster_id
+    currentCluster.aws_cluster_id
   );
   const [successfulRename, setSuccessfulRename] = useState<boolean>(false);
 
@@ -20,19 +28,23 @@ const ClusterSettings: React.FC = () => {
   const [secretKey, setSecretKey] = useState<string>("");
   const [startRotateCreds, setStartRotateCreds] = useState<boolean>(false);
   const [successfulRotate, setSuccessfulRotate] = useState<boolean>(false);
+  const [enableAgent, setEnableAgent] = useState(
+    currentCluster.agent_integration_enabled
+  );
+  const [agentLoading, setAgentLoading] = useState(false);
 
   let rotateCredentials = () => {
     api
       .overwriteAWSIntegration(
         "<token>",
         {
-          aws_integration_id: context.currentCluster.aws_integration_id,
+          aws_integration_id: currentCluster.aws_integration_id,
           aws_access_key_id: accessKeyId,
           aws_secret_access_key: secretKey,
-          cluster_id: context.currentCluster.id,
+          cluster_id: currentCluster.id,
         },
         {
-          project_id: context.currentProject.id,
+          project_id: currentProject.id,
         }
       )
       .then(({ data }) => {
@@ -45,15 +57,15 @@ const ClusterSettings: React.FC = () => {
 
   let updateClusterName = () => {
     api
-      .updateClusterName(
+      .updateCluster(
         "<token>",
         {
           name: newClusterName,
           aws_cluster_id: newAWSClusterID,
         },
         {
-          project_id: context.currentProject.id,
-          cluster_id: context.currentCluster.id,
+          project_id: currentProject.id,
+          cluster_id: currentCluster.id,
         }
       )
       .then(({ data }) => {
@@ -64,6 +76,29 @@ const ClusterSettings: React.FC = () => {
       });
   };
 
+  let updateAgentIntegrationEnabled = () => {
+    setAgentLoading(true);
+
+    api
+      .updateCluster(
+        "<token>",
+        {
+          agent_integration_enabled: enableAgent,
+        },
+        {
+          project_id: currentProject.id,
+          cluster_id: currentCluster.id,
+        }
+      )
+      .then(({ data }) => {
+        setCurrentCluster(data);
+        setAgentLoading(false);
+      })
+      .catch(() => {
+        setAgentLoading(false);
+      });
+  };
+
   let helperText = (
     <Helper>
       Delete this cluster and underlying infrastructure. To ensure that
@@ -80,7 +115,7 @@ const ClusterSettings: React.FC = () => {
     </Helper>
   );
 
-  if (!context.currentCluster?.infra_id || !context.currentCluster?.service) {
+  if (!currentCluster?.infra_id || !currentCluster?.service) {
     helperText = (
       <Helper>
         Remove this cluster from Porter. Since this cluster was not provisioned
@@ -94,8 +129,8 @@ const ClusterSettings: React.FC = () => {
   let keyRotationSection = null;
 
   if (
-    context.currentCluster?.aws_integration_id &&
-    context.currentCluster?.aws_integration_id != 0
+    currentCluster?.aws_integration_id &&
+    currentCluster?.aws_integration_id != 0
   ) {
     if (successfulRotate) {
       keyRotationSection = (
@@ -148,8 +183,8 @@ const ClusterSettings: React.FC = () => {
   }
 
   let overrideAWSClusterNameSection =
-    context.currentCluster?.aws_integration_id &&
-    context.currentCluster?.aws_integration_id != 0 ? (
+    currentCluster?.aws_integration_id &&
+    currentCluster?.aws_integration_id != 0 ? (
       <InputRow
         type="text"
         value={newAWSClusterID}
@@ -180,6 +215,28 @@ const ClusterSettings: React.FC = () => {
     </div>
   );
 
+  let enableAgentIntegration = (
+    <div>
+      <Heading>Enable Agent</Heading>
+      <CheckboxRow
+        label={"Allow the Porter agent to be installed on the cluster"}
+        toggle={() => setEnableAgent(!enableAgent)}
+        checked={enableAgent}
+      />
+      <Button color="#616FEEcc" onClick={updateAgentIntegrationEnabled}>
+        Save
+      </Button>
+    </div>
+  );
+
+  if (agentLoading) {
+    enableAgentIntegration = <Loading />;
+  }
+
+  if (capabilities.version == "production") {
+    enableAgentIntegration = null;
+  }
+
   if (successfulRename) {
     renameClusterSection = (
       <div>
@@ -192,6 +249,8 @@ const ClusterSettings: React.FC = () => {
   return (
     <div>
       <StyledSettingsSection>
+        {enableAgentIntegration}
+        <DarkMatter />
         {keyRotationSection}
         <DarkMatter />
         {renameClusterSection}
@@ -200,7 +259,7 @@ const ClusterSettings: React.FC = () => {
         {helperText}
         <Button
           color="#b91133"
-          onClick={() => context.setCurrentModal("UpdateClusterModal")}
+          onClick={() => setCurrentModal("UpdateClusterModal")}
         >
           Delete Cluster
         </Button>

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

@@ -14,7 +14,6 @@ import useAuth from "shared/auth/useAuth";
 import Metrics from "./Metrics";
 import { useLocation } from "react-router";
 import { getQueryParam } from "shared/routing";
-import IncidentsTab from "./incidents/IncidentsTab";
 
 import CopyToClipboard from "components/CopyToClipboard";
 import Loading from "components/Loading";
@@ -47,8 +46,6 @@ export const Dashboard: React.FunctionComponent = () => {
   const context = useContext(Context);
   const renderTab = () => {
     switch (currentTab) {
-      case "incidents":
-        return <IncidentsTab />;
       case "settings":
         return <ClusterSettings />;
       case "metrics":

+ 1 - 1
dashboard/src/main/home/cluster-dashboard/dashboard/NodeList.tsx

@@ -1,6 +1,6 @@
 import React, { useContext, useEffect, useMemo, useState } from "react";
 
-import Table from "components/Table";
+import Table from "components/OldTable";
 import { Column } from "react-table";
 import styled from "styled-components";
 import api from "shared/api";

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

@@ -2,7 +2,6 @@ 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";
 
 export const Routes = () => {
@@ -11,9 +10,6 @@ export const Routes = () => {
   return (
     <>
       <Switch>
-        <Route path={`${url}/incidents/:incident_id`}>
-          <IncidentPage />
-        </Route>
         <Route path={`${url}/node-view/:nodeId`}>
           <ExpandedNodeView />
         </Route>

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

@@ -1,233 +0,0 @@
-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;
-`;

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

@@ -1,62 +0,0 @@
-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;
-`;

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

@@ -1,524 +0,0 @@
-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: 1px;
-  background: #494b4f;
-  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%;
-`;

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

@@ -1,209 +0,0 @@
-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;
-`;

+ 1 - 1
dashboard/src/main/home/cluster-dashboard/dashboard/node-view/ConditionsTable.tsx

@@ -1,5 +1,5 @@
 import React, { useMemo } from "react";
-import Table from "components/Table";
+import Table from "components/OldTable";
 import { Column } from "react-table";
 import styled from "styled-components";
 

+ 1 - 1
dashboard/src/main/home/cluster-dashboard/databases/DatabasesList.tsx

@@ -1,5 +1,5 @@
 import CopyToClipboard from "components/CopyToClipboard";
-import Table from "components/Table";
+import Table from "components/OldTable";
 import React, { useContext, useEffect, useMemo, useState } from "react";
 import { useRouteMatch } from "react-router";
 import { Link } from "react-router-dom";

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

@@ -14,7 +14,7 @@ import RevisionSection from "./RevisionSection";
 import ValuesYaml from "./ValuesYaml";
 import GraphSection from "./GraphSection";
 import MetricsSection from "./metrics/MetricsSection";
-import LogsSection from "./logs-section/LogsSection";
+import LogsSection, { InitLogData } from "./logs-section/LogsSection";
 import ListSection from "./ListSection";
 import StatusSection from "./status/StatusSection";
 import SettingsSection from "./SettingsSection";
@@ -39,9 +39,9 @@ type Props = {
 };
 
 const getReadableDate = (s: string) => {
-  let ts = new Date(s);
-  let date = ts.toLocaleDateString();
-  let time = ts.toLocaleTimeString([], {
+  const ts = new Date(s);
+  const date = ts.toLocaleDateString();
+  const time = ts.toLocaleTimeString([], {
     hour: "numeric",
     minute: "2-digit",
   });
@@ -73,10 +73,12 @@ const ExpandedChart: React.FC<Props> = (props) => {
   const [imageIsPlaceholder, setImageIsPlaceholer] = useState<boolean>(false);
   const [newestImage, setNewestImage] = useState<string>(null);
   const [isLoadingChartData, setIsLoadingChartData] = useState<boolean>(true);
-  const [showRepoTooltip, setShowRepoTooltip] = useState(false);
   const [isAuthorized] = useAuth();
   const [fullScreenLogs, setFullScreenLogs] = useState<boolean>(false);
   const [isFullscreen, setIsFullscreen] = useState<boolean>(false);
+  const [logData, setLogData] = useState<InitLogData>({});
+  const [overrideCurrentTab, setOverrideCurrentTab] = useState("");
+  const [isAgentInstalled, setIsAgentInstalled] = useState<boolean>(false);
 
   const {
     isStack,
@@ -98,6 +100,11 @@ const ExpandedChart: React.FC<Props> = (props) => {
     setCurrentOverlay,
   } = useContext(Context);
 
+  const renderLogsAtTimestamp = (initLogData: InitLogData) => {
+    setLogData(initLogData);
+    setOverrideCurrentTab("logs");
+  };
+
   // Retrieve full chart data (includes form and values)
   const getChartData = async (chart: ChartType) => {
     setIsLoadingChartData(true);
@@ -175,7 +182,7 @@ const ExpandedChart: React.FC<Props> = (props) => {
     const wsConfig = {
       onmessage(evt: MessageEvent) {
         const event = JSON.parse(evt.data);
-        let object = event.Object;
+        const object = event.Object;
         object.metadata.kind = event.Kind;
 
         if (event.event_type != "UPDATE") {
@@ -242,11 +249,11 @@ const ExpandedChart: React.FC<Props> = (props) => {
       values = currentChart.config;
     }
 
-    for (let key in rawValues) {
+    for (const key in rawValues) {
       _.set(values, key, rawValues[key]);
     }
 
-    let valuesYaml = yaml.dump({
+    const valuesYaml = yaml.dump({
       ...values,
     });
 
@@ -359,9 +366,9 @@ const ExpandedChart: React.FC<Props> = (props) => {
   const handleUpgradeVersion = useCallback(
     async (version: string, cb: () => void) => {
       // convert current values to yaml
-      let values = currentChart.config;
+      const values = currentChart.config;
 
-      let valuesYaml = yaml.dump({
+      const valuesYaml = yaml.dump({
         ...values,
       });
 
@@ -393,7 +400,7 @@ const ExpandedChart: React.FC<Props> = (props) => {
 
         cb && cb();
       } catch (err) {
-        let parsedErr = err?.response?.data?.error;
+        const parsedErr = err?.response?.data?.error;
 
         if (parsedErr) {
           err = parsedErr;
@@ -413,15 +420,22 @@ const ExpandedChart: React.FC<Props> = (props) => {
   );
 
   const renderTabContents = (currentTab: string) => {
-    let { setSidebar } = props;
-    let chart = currentChart;
+    const { setSidebar } = props;
+    const chart = currentChart; // // Reset the logData when navigating to a different tab
+
     switch (currentTab) {
       case "logs":
+        if (!isAgentInstalled) {
+          return null;
+        }
+
         return (
-          <LogsSection 
+          <LogsSection
             currentChart={chart}
             isFullscreen={isFullscreen}
             setIsFullscreen={setIsFullscreen}
+            initData={logData}
+            setInitData={setLogData}
           />
         );
       case "metrics":
@@ -431,9 +445,7 @@ const ExpandedChart: React.FC<Props> = (props) => {
           return null;
         }
         return (
-          <EventsTab
-            controllers={controllers}
-          />
+          <EventsTab currentChart={chart} setLogData={renderLogsAtTimestamp} />
         );
       case "status":
         if (isLoadingChartData) {
@@ -538,14 +550,16 @@ const ExpandedChart: React.FC<Props> = (props) => {
     let leftTabOptions = [] as any[];
     if (
       currentChart.chart.metadata.home === "https://getporter.dev/" &&
-      (
-        currentChart.chart.metadata.name === "web" || 
+      (currentChart.chart.metadata.name === "web" ||
         currentChart.chart.metadata.name === "worker" ||
-        currentChart.chart.metadata.name === "job"
-      )
+        currentChart.chart.metadata.name === "job") &&
+      currentCluster.agent_integration_enabled
     ) {
       leftTabOptions.push({ label: "Events", value: "events" });
-      leftTabOptions.push({ label: "Logs", value: "logs" });
+
+      if (isAgentInstalled) {
+        leftTabOptions.push({ label: "Logs", value: "logs" });
+      }
     }
     leftTabOptions.push({ label: "Status", value: "status" });
 
@@ -576,7 +590,7 @@ const ExpandedChart: React.FC<Props> = (props) => {
 
     // Filter tabs if previewing an old revision or updating the chart version
     if (isPreview) {
-      let liveTabs = ["status", "events", "settings", "deploy", "metrics"];
+      const liveTabs = ["status", "events", "settings", "deploy", "metrics"];
       rightTabOptions = rightTabOptions.filter(
         (tab: any) => !liveTabs.includes(tab.value)
       );
@@ -695,6 +709,62 @@ const ExpandedChart: React.FC<Props> = (props) => {
     }
   };
 
+  // Check if porter agent is installed. If not installed hide the `Logs` component
+  useEffect(() => {
+    if (!currentCluster.agent_integration_enabled) {
+      return;
+    }
+
+    api
+      .detectPorterAgent(
+        "<token>",
+        {},
+        {
+          project_id: currentProject.id,
+          cluster_id: currentCluster.id,
+        }
+      )
+      .then(() => setIsAgentInstalled(true))
+      .catch((err) => {
+        setIsAgentInstalled(false);
+
+        if (err.status !== 404) {
+          setCurrentError(
+            "We could not detect the Porter agent installation status, please try again."
+          );
+        }
+      });
+  }, []);
+
+  useEffect(() => {
+    if (logData.revision) {
+      api
+        .getRevisions(
+          "<token>",
+          {},
+          {
+            id: currentProject.id,
+            namespace: props.currentChart.namespace,
+            cluster_id: currentCluster.id,
+            name: props.currentChart.name,
+          }
+        )
+        .then((res) => {
+          const chart = res.data?.find(
+            (revision: ChartType) =>
+              revision.version.toString() === logData.revision
+          );
+
+          setCurrentChart(chart ?? props.currentChart);
+        })
+        .catch(console.log);
+
+      return;
+    }
+
+    setCurrentChart(props.currentChart);
+  }, [logData, props.currentChart]);
+
   useEffect(() => {
     window.analytics?.track("Opened Chart", {
       chart: currentChart.name,
@@ -721,7 +791,7 @@ const ExpandedChart: React.FC<Props> = (props) => {
   useEffect(() => {
     updateTabs();
     localStorage.setItem("devOpsMode", devOpsMode.toString());
-  }, [devOpsMode, currentChart?.form, isPreview]);
+  }, [devOpsMode, currentChart?.form, isPreview, isAgentInstalled]);
 
   useEffect((): any => {
     let isSubscribed = true;
@@ -774,10 +844,11 @@ const ExpandedChart: React.FC<Props> = (props) => {
       ) : (
         <>
           {isFullscreen ? (
-            <LogsSection 
+            <LogsSection
               isFullscreen={true}
               setIsFullscreen={setIsFullscreen}
-              currentChart={currentChart} 
+              currentChart={currentChart}
+              setInitData={() => {}}
             />
           ) : (
             <StyledExpandedChart>
@@ -795,7 +866,8 @@ const ExpandedChart: React.FC<Props> = (props) => {
                   {currentChart.name}
                   <DeploymentType currentChart={currentChart} />
                   <TagWrapper>
-                    Namespace <NamespaceTag>{currentChart.namespace}</NamespaceTag>
+                    Namespace{" "}
+                    <NamespaceTag>{currentChart.namespace}</NamespaceTag>
                   </TagWrapper>
                 </TitleSection>
 
@@ -823,7 +895,8 @@ const ExpandedChart: React.FC<Props> = (props) => {
                   <Placeholder>
                     <TextWrap>
                       <Header>
-                        <Spinner src={loadingSrc} /> Deleting "{currentChart.name}"
+                        <Spinner src={loadingSrc} /> Deleting "
+                        {currentChart.name}"
                       </Header>
                       You will be automatically redirected after deletion is
                       complete.
@@ -875,7 +948,10 @@ const ExpandedChart: React.FC<Props> = (props) => {
                             isReadOnly={
                               isPreview ||
                               imageIsPlaceholder ||
-                              !isAuthorized("application", "", ["get", "update"])
+                              !isAuthorized("application", "", [
+                                "get",
+                                "update",
+                              ])
                             }
                             onSubmit={onSubmit}
                             includeMetadata
@@ -903,6 +979,13 @@ const ExpandedChart: React.FC<Props> = (props) => {
                                 chart: currentChart,
                               },
                             }}
+                            overrideCurrentTab={overrideCurrentTab}
+                            onTabChange={(newTab) => {
+                              if (newTab !== "logs") {
+                                setOverrideCurrentTab("");
+                                setLogData({});
+                              }
+                            }}
                           />
                         </BodyWrapper>
                       )}

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

@@ -9,7 +9,7 @@ import {
   StorageType,
 } from "shared/types";
 import api from "shared/api";
-import { getQueryParam, pushFiltered } from "shared/routing";
+import { pushFiltered } from "shared/routing";
 import { ExpandedJobChartFC } from "./ExpandedJobChart";
 import ExpandedChart from "./ExpandedChart";
 import Loading from "components/Loading";

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

@@ -1,4 +1,4 @@
-import React, { useContext, useMemo, useState } from "react";
+import React, { useContext, useEffect, useMemo, useState } from "react";
 import styled from "styled-components";
 import yaml from "js-yaml";
 
@@ -28,6 +28,7 @@ import CronParser from "cron-parser";
 import CronPrettifier from "cronstrue";
 import BuildSettingsTab from "./build-settings/BuildSettingsTab";
 import { useStackEnvGroups } from "./useStackEnvGroups";
+import api from "shared/api";
 
 const readableDate = (s: string) => {
   let ts = new Date(s);
@@ -46,7 +47,7 @@ export const ExpandedJobChartFC: React.FC<{
   closeChart: () => void;
   setSidebar: (x: boolean) => void;
 }> = ({ currentChart: oldChart, closeChart, currentCluster }) => {
-  const { setCurrentOverlay } = useContext(Context);
+  const { currentProject, setCurrentOverlay } = useContext(Context);
   const [isAuthorized] = useAuth();
   const {
     chart,

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

@@ -184,12 +184,10 @@ class RevisionSection extends Component<PropsType, StateType> {
   };
 
   handleClickRevision = (revision: ChartType) => {
-    let isCurrent = revision.version === this.state.maxVersion;
-    if (isCurrent) {
-      this.props.setRevision(revision, true);
-    } else {
-      this.props.setRevision(revision);
-    }
+    this.props.setRevision(
+      revision,
+      revision.version === this.state.maxVersion
+    );
   };
 
   renderRevisionList = () => {

+ 254 - 224
dashboard/src/main/home/cluster-dashboard/expanded-chart/events/EventList.tsx

@@ -1,177 +1,165 @@
-import React, { useState, useEffect } from "react";
+import React, { useState, useEffect, useContext } from "react";
 import { CellProps } from "react-table";
 
 import styled from "styled-components";
-import EventTable from "./EventTable";
+import Table from "components/Table";
 import Loading from "components/Loading";
 import danger from "assets/danger.svg";
+import rocket from "assets/rocket.png";
 import document from "assets/document.svg";
 import info from "assets/info-outlined.svg";
 import status from "assets/info-circle.svg";
-import { readableDate } from "shared/string_utils";
+import { readableDate, relativeDate } from "shared/string_utils";
 import TitleSection from "components/TitleSection";
 import api from "shared/api";
 import Modal from "main/home/modals/Modal";
 import time from "assets/time.svg";
-
-const iconDict: any = {
-};
+import { Context } from "shared/Context";
+import { InitLogData } from "../logs-section/LogsSection";
+import { setServers } from "dns";
 
 type Props = {
   filters: any;
-  setExpandedMonitor: any;
+  setLogData?: (logData: InitLogData) => void;
 };
 
-const EventList: React.FC<Props> = (props) => {
+const EventList: React.FC<Props> = ({ filters, setLogData }) => {
+  const { currentProject, currentCluster } = useContext(Context);
   const [events, setEvents] = useState([]);
   const [expandedEvent, setExpandedEvent] = useState(null);
+  const [expandedIncidentEvents, setExpandedIncidentEvents] = useState(null);
   const [isLoading, setIsLoading] = useState(true);
+  const [refresh, setRefresh] = useState(true);
 
   useEffect(() => {
+    if (!refresh) {
+      return;
+    }
 
-    // Dummy event list query
-    setTimeout(() => {
-      const events = [
-        {
-          "id": "e0311c9231eea5c47e8bb83cc10faf2b",
-          "release_name": "deployment-invalid-image",
-          "release_namespace": "default",
-          "chart_name": "worker",
-          "created_at": "2022-10-06T17:56:06.834088422Z",
-          "updated_at": "2022-10-07T19:32:33.842552438Z",
-          "last_seen": "2022-10-06T17:55:50Z",
-          "status": "active",
-          "summary": "The application has an invalid image",
-          "severity": "normal",
-          "involved_object_kind": "pod",
-          "involved_object_name": "deployment-invalid-image-worker-cf976b476-h782c",
-          "involved_object_namespace": "default"
-        },
-        {
-          "id": "6818ee8df06db55c310c24a6b0c17cfd",
-          "release_name": "oom-killed",
-          "release_namespace": "default",
-          "chart_name": "web",
-          "created_at": "2022-10-05T17:13:45.563510898Z",
-          "updated_at": "2022-10-07T19:32:02.580935074Z",
-          "last_seen": "2022-10-05T17:59:10Z",
-          "status": "active",
-          "summary": "The application ran out of memory",
-          "severity": "critical",
-          "involved_object_kind": "Deployment",
-          "involved_object_name": "oom-killed-web",
-          "involved_object_namespace": "default"
-        },
-        {
-          "id": "00f3b38dd0b2adc2ce0bab9586d487b5",
-          "release_name": "non-zero-exit-code",
-          "release_namespace": "default",
-          "chart_name": "web",
-          "created_at": "2022-10-05T18:31:07.80325966Z",
-          "updated_at": "2022-10-07T19:31:34.591091925Z",
-          "last_seen": "2022-10-05T18:31:07Z",
-          "status": "active",
-          "summary": "The application exited with a non-zero exit code",
-          "severity": "normal",
-          "involved_object_kind": "pod",
-          "involved_object_name": "non-zero-exit-code-web-797d5ddb64-g5d7x",
-          "involved_object_namespace": "default"
-        },
-        {
-          "id": "c435fe96260e472af9808013687a876c",
-          "release_name": "multi-replica-failure-less",
-          "release_namespace": "default",
-          "chart_name": "worker",
-          "created_at": "2022-10-07T15:05:16.823354171Z",
-          "updated_at": "2022-10-07T19:31:29.840878022Z",
-          "last_seen": "2022-10-07T15:09:17Z",
-          "status": "active",
-          "summary": "The application exited with a non-zero exit code",
-          "severity": "critical",
-          "involved_object_kind": "Deployment",
-          "involved_object_name": "multi-replica-failure-less-worker",
-          "involved_object_namespace": "default"
-        },
-        {
-          "id": "d9aec89e437617f28de47ab700c92cb4",
-          "release_name": "multi-replica-failure-more",
-          "release_namespace": "default",
-          "chart_name": "worker",
-          "created_at": "2022-10-07T15:08:21.405351792Z",
-          "updated_at": "2022-10-07T19:31:14.773187536Z",
-          "last_seen": "2022-10-07T19:25:06Z",
-          "status": "active",
-          "summary": "The application exited with a non-zero exit code",
-          "severity": "critical",
-          "involved_object_kind": "Deployment",
-          "involved_object_name": "multi-replica-failure-more-worker",
-          "involved_object_namespace": "default"
-        },
+    if (filters.job_name) {
+      api
+        .listPorterJobEvents("<token>", filters, {
+          project_id: currentProject.id,
+          cluster_id: currentCluster.id,
+        })
+        .then((res) => {
+          setEvents(res.data.events);
+          setIsLoading(false);
+          setRefresh(false);
+        });
+    } else {
+      api
+        .listPorterEvents("<token>", filters, {
+          project_id: currentProject.id,
+          cluster_id: currentCluster.id,
+        })
+        .then((res) => {
+          setEvents(res.data.events);
+          setIsLoading(false);
+          setRefresh(false);
+        });
+    }
+  }, [refresh]);
+
+  useEffect(() => {
+    if (!expandedEvent) {
+      return;
+    }
+
+    api
+      .getIncidentEvents(
+        "<token>",
         {
-          "id": "f7f2021acb0e5fd104fb30b8c914a563",
-          "release_name": "oom-killed-2",
-          "release_namespace": "default",
-          "chart_name": "web",
-          "created_at": "2022-10-05T18:00:45.655719615Z",
-          "updated_at": "2022-10-07T19:28:38.571531252Z",
-          "last_seen": "2022-10-05T18:00:44Z",
-          "status": "active",
-          "summary": "The application ran out of memory",
-          "severity": "critical",
-          "involved_object_kind": "deployment",
-          "involved_object_name": "oom-killed-2-web",
-          "involved_object_namespace": "default"
+          incident_id: expandedEvent.id,
         },
         {
-          "id": "fa76cfb2daa58649e6aa7dc47262f632",
-          "release_name": "deployment-bad-image-tag",
-          "release_namespace": "default",
-          "chart_name": "worker",
-          "created_at": "2022-10-06T17:51:04.846528578Z",
-          "updated_at": "2022-10-07T19:28:11.835881268Z",
-          "last_seen": "2022-10-06T17:50:46Z",
-          "status": "active",
-          "summary": "The application has an invalid image",
-          "severity": "normal",
-          "involved_object_kind": "pod",
-          "involved_object_name": "deployment-bad-image-tag-worker-5f676bdb9-q4d7w",
-          "involved_object_namespace": "default"
-        },
+          project_id: currentProject.id,
+          cluster_id: currentCluster.id,
+        }
+      )
+      .then((res) => {
+        setExpandedIncidentEvents(res.data.events);
+      });
+  }, [expandedEvent]);
+
+  const redirectToLogs = (incident: any) => {
+    api
+      .getIncidentEvents(
+        "<token>",
         {
-          "id": "b58f6e28bc3884f50cb60c93b8427cb2",
-          "release_name": "deployment-stuck-longtime",
-          "release_namespace": "default",
-          "chart_name": "worker",
-          "created_at": "2022-10-07T15:04:23.807783654Z",
-          "updated_at": "2022-10-07T17:41:27.155369452Z",
-          "last_seen": "2022-10-07T15:04:23.695228777Z",
-          "status": "active",
-          "summary": "The application cannot be scheduled",
-          "severity": "critical",
-          "involved_object_kind": "deployment",
-          "involved_object_name": "deployment-stuck-longtime-worker",
-          "involved_object_namespace": "default"
+          incident_id: incident.id,
         },
         {
-          "id": "6c6cc62a398d831bfddb6ef5faceafa9",
-          "release_name": "failing-job-run",
-          "release_namespace": "default",
-          "chart_name": "job",
-          "created_at": "2022-10-07T16:43:06.025081995Z",
-          "updated_at": "2022-10-07T17:29:02.722048271Z",
-          "last_seen": "2022-10-07T16:43:48Z",
-          "status": "active",
-          "summary": "The application has an invalid start command",
-          "severity": "normal",
-          "involved_object_kind": "Job",
-          "involved_object_name": "failing-job-run-dr1vbs563d",
-          "involved_object_namespace": "default"
+          project_id: currentProject.id,
+          cluster_id: currentCluster.id,
         }
-      ];
-      setEvents(events);
-      setIsLoading(false);
-    }, 1000);
-  }, []);
+      )
+      .then((res) => {
+        const podName = res.data?.events[0]?.pod_name;
+        const timestamp = res.data?.events[0]?.last_seen;
+        const revision = res.data?.events[0]?.revision;
+
+        setLogData({
+          podName,
+          timestamp,
+          revision,
+        });
+      });
+  };
+
+  const renderExpandedEventMessage = () => {
+    if (!expandedIncidentEvents) {
+      return <Loading />;
+    }
+
+    return (
+      <Message>
+        <img src={document} />
+        {expandedIncidentEvents[0].detail}
+      </Message>
+    );
+  };
+
+  const renderIncidentSummaryCell = (incident: any) => {
+    return (
+      <NameWrapper>
+        <AlertIcon src={danger} />
+        {incident.short_summary}
+        {incident.severity === "normal" ? (
+          <></>
+        ) : (
+          <Status color="#cc3d42">Critical</Status>
+        )}
+      </NameWrapper>
+    );
+  };
+
+  const renderDeploymentFinishedCell = (release: any) => {
+    return (
+      <NameWrapper>
+        <AlertIcon src={rocket} />
+        Revision {release.revision} was successfully deployed
+      </NameWrapper>
+    );
+  };
+
+  const renderJobStartedCell = (timestamp: any) => {
+    return (
+      <NameWrapper>
+        <AlertIcon src={time} />
+        The job started at {readableDate(timestamp)}
+      </NameWrapper>
+    );
+  };
+
+  const renderJobFinishedCell = (timestamp: any) => {
+    return (
+      <NameWrapper>
+        <AlertIcon src={time} />
+        The job finished at {readableDate(timestamp)}
+      </NameWrapper>
+    );
+  };
 
   const columns = React.useMemo(
     () => [
@@ -179,39 +167,29 @@ const EventList: React.FC<Props> = (props) => {
         Header: "Monitors",
         columns: [
           {
-            Header: "Name",
-            accessor: "release_name",
-            width: 180,
+            Header: "Description",
+            accessor: "type",
+            width: 500,
             Cell: ({ row }: CellProps<any>) => {
-              return (
-                <NameWrapper>
-                  <AlertIcon src={danger} />
-                  {row.original.release_name}
-                  {row?.original &&
-                  row.original.severity === "normal" ? (
-                    <></>
-                  ) : (
-                    <Status color="#cc3d42">Critical</Status>
-                  )}
-                </NameWrapper>
-              );
+              if (row.original.type == "incident") {
+                return renderIncidentSummaryCell(row.original.data);
+              } else if (row.original.type == "deployment_finished") {
+                return renderDeploymentFinishedCell(row.original.data);
+              } else if (row.original.type == "job_started") {
+                return renderJobStartedCell(row.original.timestamp);
+              } else if (row.original.type == "job_finished") {
+                return renderJobFinishedCell(row.original.timestamp);
+              }
+
+              return null;
             },
           },
           {
-            Header: "Summary",
-            accessor: "summary",
-            width: 270,
-          },
-          {
-            Header: "Last updated",
-            accessor: "updated_at",
+            Header: "Last Seen",
+            accessor: "timestamp",
             width: 140,
             Cell: ({ row }: CellProps<any>) => {
-              return (
-                <Flex>
-                  {readableDate(row.original.updated_at)}
-                </Flex>
-              );
+              return <Flex>{relativeDate(row.original.timestamp)}</Flex>;
             },
           },
           {
@@ -219,14 +197,20 @@ const EventList: React.FC<Props> = (props) => {
             accessor: "",
             width: 20,
             Cell: ({ row }: CellProps<any>) => {
-              return (
-                <TableButton onClick={() => {
-                  setExpandedEvent(row.original);
-                }}>
-                  <Icon src={info} />
-                  Details
-                </TableButton>
-              );
+              if (row.original.type == "incident") {
+                return (
+                  <TableButton
+                    onClick={() => {
+                      setExpandedEvent(row.original.data);
+                    }}
+                  >
+                    <Icon src={info} />
+                    Details
+                  </TableButton>
+                );
+              }
+
+              return null;
             },
           },
           {
@@ -234,8 +218,21 @@ const EventList: React.FC<Props> = (props) => {
             accessor: "",
             width: 30,
             Cell: ({ row }: CellProps<any>) => {
+              if (row.original.type != "incident") {
+                return null;
+              }
+
+              if (!row.original.data.should_view_logs) {
+                return null;
+              }
+
               return (
-                <TableButton width="102px">
+                <TableButton
+                  width="102px"
+                  onClick={() => {
+                    redirectToLogs(row.original.data);
+                  }}
+                >
                   <Icon src={document} />
                   View logs
                 </TableButton>
@@ -250,53 +247,53 @@ const EventList: React.FC<Props> = (props) => {
 
   return (
     <>
-      {
-        expandedEvent && (
-          <Modal onRequestClose={() => setExpandedEvent(null)} height="auto">
-            <TitleSection icon={danger}>
-              <Text>{expandedEvent.release_name}</Text>
-            </TitleSection>
-            <InfoRow>
-              <InfoTab>
-                <img src={time} /> <Bold>Last updated:</Bold>
-                {readableDate(expandedEvent.updated_at)}
-              </InfoTab>
-              <InfoTab>
-                <img src={info} /> <Bold>Status:</Bold>
-                <Capitalize>{expandedEvent.status}</Capitalize>
-              </InfoTab>
-              <InfoTab>
-                <img src={status} /> <Bold>Priority:</Bold> <Capitalize>{expandedEvent.severity}</Capitalize>
-              </InfoTab>
-            </InfoRow>
-            <Message>
-              <img src={document} /> This is a placeholder message where event details should be.
-            </Message>
-          </Modal>
-        )
-      }
+      {expandedEvent && (
+        <Modal onRequestClose={() => setExpandedEvent(null)} height="auto">
+          <TitleSection icon={danger}>
+            <Text>{expandedEvent.release_name}</Text>
+          </TitleSection>
+          <InfoRow>
+            <InfoTab>
+              <img src={time} /> <Bold>Last updated:</Bold>
+              {readableDate(expandedEvent.updated_at)}
+            </InfoTab>
+            <InfoTab>
+              <img src={info} /> <Bold>Status:</Bold>
+              <Capitalize>{expandedEvent.status}</Capitalize>
+            </InfoTab>
+            <InfoTab>
+              <img src={status} /> <Bold>Priority:</Bold>{" "}
+              <Capitalize>{expandedEvent.severity}</Capitalize>
+            </InfoTab>
+          </InfoRow>
+          {renderExpandedEventMessage()}
+        </Modal>
+      )}
       {isLoading ? (
         <LoadWrapper>
           <Loading />
         </LoadWrapper>
       ) : (
-        <>
-        {events.length > 0 ? (
-          <TableWrapper>
-            <EventTable 
-              columns={columns} 
-              data={events} 
-            />
-          </TableWrapper>
-        ) : (
-          <Placeholder>
-            <div>
-            <Title>No results found</Title>
-            There were no results found for this filter.
-            </div>
-          </Placeholder>
-        )}
-        </>
+        <TableWrapper>
+          <Table 
+            columns={columns} 
+            data={events} 
+            placeholder="No events found."
+          />
+          <FlexRow>
+            <Flex>
+              <Button
+                onClick={() => {
+                  setIsLoading(true);
+                  setRefresh(true);
+                }}
+              >
+                <i className="material-icons">autorenew</i>
+                Refresh
+              </Button>
+            </Flex>
+          </FlexRow>
+        </TableWrapper>
       )}
     </>
   );
@@ -306,7 +303,7 @@ export default EventList;
 
 const Message = styled.div`
   padding: 20px;
-  background: #26292E;
+  background: #26292e;
   border-radius: 5px;
   line-height: 1.5em;
   border: 1px solid #aaaabb33;
@@ -364,7 +361,7 @@ const TableButton = styled.div<{ width?: string }>`
   border-radius: 5px;
   height: 30px;
   color: white;
-  width: ${props => props.width || "85px"};
+  width: ${(props) => props.width || "85px"};
   display: flex;
   align-items: center;
   justify-content: center;
@@ -459,3 +456,36 @@ const StyledMonitorList = styled.div`
   border-radius: 5px;
   border: 1px solid #aaaabb33;
 `;
+
+const NoResultsFoundWrapper = styled(Flex)`
+  flex-direction: column;
+  justify-contents: center;
+`;
+
+const Button = styled.div`
+  background: #26292e;
+  border-radius: 5px;
+  height: 30px;
+  font-size: 13px;
+  display: flex;
+  cursor: pointer;
+  align-items: center;
+  padding: 10px;
+  padding-left: 8px;
+  > i {
+    font-size: 16px;
+    margin-right: 5px;
+  }
+  border: 1px solid #494b4f;
+  :hover {
+    border: 1px solid #7a7b80;
+  }
+`;
+
+const FlexRow = styled.div`
+  display: flex;
+  align-items: center;
+  justify-content: flex-end;
+  flex-wrap: wrap;
+  margin-top: 20px;
+`;

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

@@ -1,94 +0,0 @@
-import React from "react";
-import {
-  Column,
-  Row,
-  useGlobalFilter,
-  usePagination,
-  useTable,
-} from "react-table";
-import {
-  StyledTd,
-  StyledTable,
-  StyledTHead,
-  StyledTh,
-  StyledTBody,
-} from "./styles";
-
-export type TableProps = {
-  columns: Column<any>[];
-  data: any[];
-  onRowClick?: (row: Row) => void;
-};
-
-const EventTable: React.FC<TableProps> = ({
-  columns: columnsData,
-  data,
-  onRowClick,
-}) => {
-  const {
-    rows,
-    getTableProps,
-    getTableBodyProps,
-    prepareRow,
-    headerGroups,
-  } = useTable(
-    {
-      columns: columnsData,
-      data,
-    },
-    useGlobalFilter,
-    usePagination
-  );
-
-  const renderRows = () => {
-    return (
-      <>
-        {rows.map((row: any) => {
-          prepareRow(row);
-
-          return (
-            <tr
-              {...row.getRowProps()}
-              onClick={() => onRowClick && onRowClick(row)}
-              selected={false}
-            >
-              {row.cells.map((cell: any) => {
-                return (
-                  <StyledTd
-                    {...cell.getCellProps()}
-                    style={{
-                      width: cell.column.totalWidth,
-                    }}
-                  >
-                    {cell.render("Cell")}
-                  </StyledTd>
-                );
-              })}
-            </tr>
-          );
-        })}
-      </>
-    );
-  };
-
-  return (
-    <>
-      <StyledTable {...getTableProps()}>
-        <StyledTHead>
-          {headerGroups.map((headerGroup) => (
-            <tr {...headerGroup.getHeaderGroupProps()}>
-              {headerGroup.headers.map((column) => (
-                <StyledTh {...column.getHeaderProps()}>
-                  {column.render("Header")}
-                </StyledTh>
-              ))}
-            </tr>
-          ))}
-        </StyledTHead>
-        <StyledTBody {...getTableBodyProps()}>{renderRows()}</StyledTBody>
-      </StyledTable>
-    </>
-  );
-};
-
-export default EventTable;

+ 44 - 3
dashboard/src/main/home/cluster-dashboard/expanded-chart/events/EventsTab.tsx

@@ -6,14 +6,40 @@ import Loading from "components/Loading";
 import InfiniteScroll from "react-infinite-scroll-component";
 import { Context } from "shared/Context";
 import Dropdown from "components/Dropdown";
+import { InitLogData } from "../logs-section/LogsSection";
 
-const EventsTab: React.FC = () => {
+type Props = {
+  currentChart: any;
+  setLogData?: (logData: InitLogData) => void;
+  overridingJobName?: string;
+};
+
+const EventsTab: React.FC<Props> = ({
+  currentChart,
+  setLogData,
+  overridingJobName,
+}) => {
   const [hasPorterAgent, setHasPorterAgent] = useState(true);
   const { currentProject, currentCluster } = useContext(Context);
   const [isLoading, setIsLoading] = useState(true);
 
   useEffect(() => {
-    setIsLoading(false);
+    const project_id = currentProject?.id;
+    const cluster_id = currentCluster?.id;
+
+    // determine if the agent is installed properly - if not, render upgrade screen
+    api
+      .detectPorterAgent("<token>", {}, { project_id, cluster_id })
+      .then((res) => {
+        console.log(res.data);
+        setIsLoading(false);
+      })
+      .catch((err) => {
+        if (err.response?.status === 404) {
+          setHasPorterAgent(false);
+          setIsLoading(false);
+        }
+      });
   }, []);
 
   const installAgent = async () => {
@@ -34,6 +60,21 @@ const EventsTab: React.FC = () => {
     installAgent();
   };
 
+  const getFilters = () => {
+    if (overridingJobName) {
+      return {
+        release_name: currentChart.name,
+        release_namespace: currentChart.namespace,
+        job_name: overridingJobName,
+      };
+    }
+
+    return {
+      release_name: currentChart.name,
+      release_namespace: currentChart.namespace,
+    };
+  };
+
   if (isLoading) {
     return (
       <Placeholder>
@@ -58,7 +99,7 @@ const EventsTab: React.FC = () => {
 
   return (
     <EventsPageWrapper>
-      <EventList />
+      <EventList setLogData={setLogData} filters={getFilters()} />
     </EventsPageWrapper>
   );
 };

+ 0 - 216
dashboard/src/main/home/cluster-dashboard/expanded-chart/incidents/EventsTab.tsx

@@ -1,216 +0,0 @@
-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;
-  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``;

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

@@ -1,217 +0,0 @@
-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;
-`;

+ 96 - 61
dashboard/src/main/home/cluster-dashboard/expanded-chart/jobs/ExpandedJobRun.tsx

@@ -5,7 +5,7 @@ import styled from "styled-components";
 import leftArrow from "assets/left-arrow.svg";
 import KeyValueArray from "components/form-components/KeyValueArray";
 import Loading from "components/Loading";
-import TabRegion from "components/TabRegion";
+import TabRegion, { TabOption } from "components/TabRegion";
 import TitleSection from "components/TitleSection";
 import api from "shared/api";
 import { Context } from "shared/Context";
@@ -14,6 +14,9 @@ import DeploymentType from "../DeploymentType";
 import JobMetricsSection from "../metrics/JobMetricsSection";
 import Logs from "../status/Logs";
 import { useRouting } from "shared/routing";
+import Banner from "components/Banner";
+import LogsSection from "../logs-section/LogsSection";
+import EventsTab from "../events/EventsTab";
 
 const readableDate = (s: string) => {
   let ts = new Date(s);
@@ -48,42 +51,13 @@ const getLatestPod = (pods: any[]) => {
     .shift();
 };
 
-const renderStatus = (job: any, pods: any[], time: string) => {
+const renderStatus = (job: any, time: string) => {
   if (job.status?.succeeded >= 1) {
     return <Status color="#38a88a">Succeeded {time}</Status>;
   }
 
   if (job.status?.failed >= 1) {
-    const appPod = getLatestPod(pods);
-
-    if (appPod) {
-      const appContainerStatus = appPod?.status?.containerStatuses?.find(
-        (container: any) =>
-          container?.state?.terminated?.reason !== "Completed" &&
-          !container?.state?.running
-      );
-
-      if (appContainerStatus) {
-        const reason = appContainerStatus.state.terminated.reason;
-        const exitCode = appContainerStatus.state.terminated.exitCode;
-        const finishTime = appContainerStatus.state.terminated.finishedAt;
-
-        return (
-          <Status color="#cc3d42">
-            Failed at {time ? time : readableDate(finishTime)} - Reason:{" "}
-            {reason} - Exit Code: {exitCode}
-          </Status>
-        );
-      }
-    }
-
-    return (
-      <Status color="#cc3d42">
-        Failed {time}
-        {job.status.conditions.length > 0 &&
-          `: ${job.status.conditions[0].reason}`}
-      </Status>
-    );
+    return <Status color="#cc3d42">Failed</Status>;
   }
 
   return <Status color="#ffffff11">Running</Status>;
@@ -102,11 +76,12 @@ const ExpandedJobRun = ({
     Context
   );
   const [currentTab, setCurrentTab] = useState<
-    "logs" | "metrics" | "config" | string
-  >("logs");
+    "events" | "logs" | "metrics" | "config" | string
+  >(currentCluster.agent_integration_enabled ? "events" : "logs");
   const [pods, setPods] = useState<any>(null);
   const [isLoading, setIsLoading] = useState(true);
   const { pushQueryParams } = useRouting();
+  const [useDeprecatedLogs, setUseDeprecatedLogs] = useState(false);
 
   let chart = currentChart;
   let run = jobRun;
@@ -191,10 +166,80 @@ const ExpandedJobRun = ({
     );
   };
 
+  const renderEventsSection = () => {
+    return (
+      <EventsTab
+        currentChart={currentChart}
+        overridingJobName={jobRun.metadata?.name}
+      />
+    );
+  };
+
+  const renderLogsSection = () => {
+    if (useDeprecatedLogs || !currentCluster.agent_integration_enabled) {
+      return (
+        <JobLogsWrapper>
+          <Logs
+            selectedPod={pods[0]}
+            podError={!pods[0] ? "Pod no longer exists." : ""}
+            rawText={true}
+          />
+        </JobLogsWrapper>
+      );
+    }
+
+    return (
+      <JobLogsWrapper>
+        <DeprecatedWarning>
+          Not seeing your logs? Switch back to{" "}
+          <DeprecatedSelect
+            onClick={() => {
+              setUseDeprecatedLogs(true);
+            }}
+          >
+            {" "}
+            deprecated logging.
+          </DeprecatedSelect>
+        </DeprecatedWarning>
+        <LogsSection
+          isFullscreen={false}
+          setIsFullscreen={() => {}}
+          overridingPodName={pods[0]?.metadata?.name || jobRun.metadata?.name}
+          setInitData={() => {}}
+          currentChart={currentChart}
+        />
+      </JobLogsWrapper>
+    );
+  };
+
   if (isLoading) {
     return <Loading />;
   }
 
+  let options: TabOption[] = [];
+
+  if (currentCluster.agent_integration_enabled) {
+    options.push({
+      label: "Events",
+      value: "events",
+    });
+  }
+
+  options.push(
+    {
+      label: "Logs",
+      value: "logs",
+    },
+    {
+      label: "Metrics",
+      value: "metrics",
+    },
+    {
+      label: "Config",
+      value: "config",
+    }
+  );
+
   return (
     <StyledExpandedChart>
       <BreadcrumbRow>
@@ -207,12 +252,10 @@ const ExpandedJobRun = ({
         <TitleSection icon={currentChart.chart.metadata.icon} iconWidth="33px">
           {chart.name} <Gray>at {readableDate(run.status.startTime)}</Gray>
         </TitleSection>
-
         <InfoWrapper>
           <LastDeployed>
             {renderStatus(
               run,
-              pods,
               run.status.completionTime
                 ? readableDate(run.status.completionTime)
                 : ""
@@ -228,30 +271,10 @@ const ExpandedJobRun = ({
         <TabRegion
           currentTab={currentTab}
           setCurrentTab={(x: string) => setCurrentTab(x)}
-          options={[
-            {
-              label: "Logs",
-              value: "logs",
-            },
-            {
-              label: "Metrics",
-              value: "metrics",
-            },
-            {
-              label: "Config",
-              value: "config",
-            },
-          ]}
+          options={options}
         >
-          {currentTab === "logs" && (
-            <JobLogsWrapper>
-              <Logs
-                selectedPod={pods[0]}
-                podError={!pods[0] ? "Pod no longer exists." : ""}
-                rawText={true}
-              />
-            </JobLogsWrapper>
-          )}
+          {currentTab === "events" && renderEventsSection()}
+          {currentTab === "logs" && renderLogsSection()}
           {currentTab === "config" && <>{renderConfigSection(run)}</>}
           {currentTab === "metrics" && (
             <JobMetricsSection jobChart={currentChart} jobRun={run} />
@@ -323,10 +346,9 @@ const ConfigSection = styled.div`
 
 const JobLogsWrapper = styled.div`
   min-height: 450px;
-  height: 55vh;
+  height: 65vh;
   width: 100%;
   border-radius: 8px;
-  background-color: black;
   overflow-y: auto;
 `;
 
@@ -468,3 +490,16 @@ const StyledExpandedChart = styled.div`
     }
   }
 `;
+
+const DeprecatedWarning = styled.div`
+  font-size: 12px;
+  color: #ccc;
+  text-align: right;
+  width: 100%;
+  margin-bottom: 20px;
+`;
+
+const DeprecatedSelect = styled.span`
+  cursor: pointer;
+  color: #949effff;
+`;

+ 3 - 3
dashboard/src/main/home/cluster-dashboard/expanded-chart/jobs/JobResource.tsx

@@ -323,7 +323,7 @@ export default class JobResource extends Component<PropsType, StateType> {
     return (
       <>
         <StyledJob>
-          <MainRow onClick={this.expandJob}>
+          <MainRow onClick={() => this.props.expandJob(this.props.job)}>
             <Flex>
               <Icon src={icon && icon} />
               <Description>
@@ -360,12 +360,12 @@ export default class JobResource extends Component<PropsType, StateType> {
                     delete
                   </i>
                 )}
-                <i
+                {/* <i
                   className="material-icons"
                   onClick={() => this.props.expandJob(this.props.job)}
                 >
                   open_in_new
-                </i>
+                </i> */}
               </MaterialIconTray>
             </EndWrapper>
           </MainRow>

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

@@ -1,31 +1,200 @@
-import React, { useEffect, useState } from "react";
+import React, {
+  useCallback,
+  useContext,
+  useEffect,
+  useRef,
+  useState,
+} from "react";
 
 import styled from "styled-components";
 import RadioFilter from "components/RadioFilter";
 
 import filterOutline from "assets/filter-outline.svg";
+import time from "assets/time.svg";
+import { Context } from "shared/Context";
+import api from "shared/api";
+import { Direction, useLogs } from "./useAgentLogs";
+import Anser from "anser";
 import DateTimePicker from "components/date-time-picker/DateTimePicker";
+import dayjs from "dayjs";
+import Loading from "components/Loading";
+import _ from "lodash";
+import { ChartType } from "shared/types";
+
+export type InitLogData = Partial<{
+  podName: string;
+  timestamp: string;
+  revision: string;
+}>;
 
 type Props = {
-  currentChart?: any;
+  currentChart?: ChartType;
   isFullscreen: boolean;
   setIsFullscreen: (x: boolean) => void;
+  initData?: InitLogData;
+  setInitData?: (initData: InitLogData) => void;
+  overridingPodName?: string;
+};
+
+const escapeRegExp = (str: string) => {
+  return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
+};
+
+interface QueryModeSelectionToggleProps {
+  selectedDate?: Date;
+  setSelectedDate: React.Dispatch<React.SetStateAction<Date>>;
+}
+
+const QueryModeSelectionToggle = (props: QueryModeSelectionToggleProps) => {
+  return (
+    <div
+      style={{
+        marginRight: "10px",
+        display: "flex",
+        gap: "10px",
+      }}
+    >
+      <ToggleButton>
+        <ToggleOption
+          onClick={() => props.setSelectedDate(undefined)}
+          selected={!props.selectedDate}
+        >
+          <Dot selected={!props.selectedDate} />
+          Live
+        </ToggleOption>
+        <ToggleOption
+          nudgeLeft
+          onClick={() => props.setSelectedDate(dayjs().toDate())}
+          selected={!!props.selectedDate}
+        >
+          <TimeIcon src={time} selected={!!props.selectedDate} />
+          {props.selectedDate && (
+            <DateTimePicker
+              startDate={props.selectedDate}
+              setStartDate={props.setSelectedDate}
+            />
+          )}
+        </ToggleOption>
+      </ToggleButton>
+    </div>
+  );
 };
 
+const Dot = styled.div<{ selected?: boolean }>`
+  display: inline-black;
+  width: 8px;
+  height: 8px;
+  margin-right: 9px;
+  border-radius: 20px;
+  background: ${(props) => (props.selected ? "#ed5f85" : "#ffffff22")};
+  border: 0px;
+  outline: none;
+  box-shadow: ${(props) => (props.selected ? "0px 0px 5px 1px #ed5f85" : "")};
+`;
+
 const LogsSection: React.FC<Props> = ({
   currentChart,
   isFullscreen,
   setIsFullscreen,
+  initData = {},
+  setInitData,
+  overridingPodName,
 }) => {
-  const [podFilter, setPodFilter] = useState("pod-a");
-  const [scrollToBottom, setScrollToBottom] = useState(true);
+  const scrollToBottomRef = useRef<HTMLDivElement | undefined>(undefined);
+  const { currentProject, currentCluster } = useContext(Context);
+  const [podFilter, setPodFilter] = useState(
+    initData.podName || overridingPodName
+  );
+  const [podFilterOpts, setPodFilterOpts] = useState<string[]>(
+    initData?.podName
+      ? _.compact([initData.podName])
+      : overridingPodName
+      ? _.compact([overridingPodName])
+      : []
+  );
+  const [scrollToBottomEnabled, setScrollToBottomEnabled] = useState(true);
   const [searchText, setSearchText] = useState("");
+  const [enteredSearchText, setEnteredSearchText] = useState("");
+  const [selectedDate, setSelectedDate] = useState<Date | undefined>(
+    initData.timestamp ? dayjs(initData.timestamp).toDate() : undefined
+  );
+
+  const { loading, logs, refresh, moveCursor, paginationInfo } = useLogs(
+    podFilter,
+    currentChart.namespace,
+    enteredSearchText,
+    currentChart,
+    selectedDate
+  );
 
   useEffect(() => {
-    console.log(currentChart);
+    if (overridingPodName) {
+      return;
+    }
+
+    api
+      .getLogPodValues(
+        "<TOKEN>",
+        {
+          revision: initData.revision ?? currentChart.version.toString(),
+          match_prefix: currentChart.name,
+        },
+        {
+          project_id: currentProject.id,
+          cluster_id: currentCluster.id,
+        }
+      )
+      .then((res: any) => {
+        setPodFilterOpts(_.uniq(res.data ?? []));
+
+        // only set pod filter if the current pod is not found in the resulting data
+        if (!res.data?.includes(podFilter)) {
+          setPodFilter(res.data[0]);
+        }
+      });
   }, []);
 
-  const [startDate, setStartDate] = useState(new Date());
+  useEffect(() => {
+    if (!loading && scrollToBottomRef.current && scrollToBottomEnabled) {
+      scrollToBottomRef.current.scrollIntoView({
+        behavior: "smooth",
+        block: "end",
+      });
+    }
+  }, [loading, logs, scrollToBottomRef, scrollToBottomEnabled]);
+
+  const renderLogs = () => {
+    return logs?.map((log, i) => {
+      return (
+        <Log key={[log.lineNumber, i].join(".")}>
+          <span className="line-number">{log.lineNumber}.</span>
+          <span className="line-timestamp">
+            {dayjs(log.timestamp).format("MMM D, YYYY HH:mm:ss")}
+          </span>
+          {log.line?.map((ansi, j) => {
+            if (ansi.clearLine) {
+              return null;
+            }
+
+            return (
+              <LogSpan key={[log.lineNumber, i, j].join(".")} ansi={ansi}>
+                {ansi.content.replace(/ /g, "\u00a0")}
+              </LogSpan>
+            );
+          })}
+        </Log>
+      );
+    });
+  };
+
+  const onLoadPrevious = useCallback(() => {
+    if (!selectedDate) {
+      setSelectedDate(dayjs(logs[0].timestamp).toDate());
+      return;
+    }
+
+    moveCursor(Direction.backward);
+  }, [logs, selectedDate]);
 
   const renderContents = () => {
     return (
@@ -38,47 +207,43 @@ const LogsSection: React.FC<Props> = ({
                 <SearchInput
                   value={searchText}
                   onChange={(e: any) => {
-                    setSearchText(e.value);
+                    setSearchText(e.target.value);
+                  }}
+                  onKeyPress={(event) => {
+                    if (event.key === "Enter") {
+                      setEnteredSearchText(escapeRegExp(searchText));
+                    }
                   }}
                   placeholder="Search logs..."
                 />
               </SearchBarWrapper>
             </SearchRowWrapper>
-            <DateTimePicker startDate={startDate} setStartDate={setStartDate} />
+            <QueryModeSelectionToggle
+              selectedDate={selectedDate}
+              setSelectedDate={setSelectedDate}
+            />
             <RadioFilter
               icon={filterOutline}
               selected={podFilter}
               setSelected={setPodFilter}
-              options={[
-                {
-                  value: "pod-a",
-                  label: "Pod A",
-                },
-                {
-                  value: "pod-b",
-                  label: "Pod B",
-                },
-                {
-                  value: "pod-c",
-                  label: "Pod C",
-                },
-                {
-                  value: "pod-d",
-                  label: "Pod D",
-                },
-              ]}
+              options={podFilterOpts?.map((name) => {
+                return {
+                  value: name,
+                  label: name,
+                };
+              })}
               name="Filter logs"
             />
           </Flex>
           <Flex>
-            <Button onClick={() => setScrollToBottom(!scrollToBottom)}>
-              <Checkbox checked={scrollToBottom}>
+            <Button onClick={() => setScrollToBottomEnabled((s) => !s)}>
+              <Checkbox checked={scrollToBottomEnabled}>
                 <i className="material-icons">done</i>
               </Checkbox>
               Scroll to bottom
             </Button>
             <Spacer />
-            <Button>
+            <Button onClick={() => refresh()}>
               <i className="material-icons">autorenew</i>
               Refresh
             </Button>
@@ -93,13 +258,38 @@ const LogsSection: React.FC<Props> = ({
           </Flex>
         </FlexRow>
         <StyledLogsSection isFullscreen={isFullscreen}>
-          <Message>
+          {loading || !logs.length ? (
+            <Loading message="Waiting for logs..." />
+          ) : (
+            <>
+              <LoadMoreButton
+                active={
+                  logs.length !== 0 && paginationInfo.previousCursor !== null
+                }
+                role="button"
+                onClick={onLoadPrevious}
+              >
+                Load Previous
+              </LoadMoreButton>
+              {renderLogs()}
+              {/* <Message>
+            
             No matching logs found.
             <Highlight onClick={() => {}}>
               <i className="material-icons">autorenew</i>
               Refresh
             </Highlight>
-          </Message>
+          </Message> */}
+              <LoadMoreButton
+                active={selectedDate && logs.length !== 0}
+                role="button"
+                onClick={() => moveCursor(Direction.forward)}
+              >
+                Load more
+              </LoadMoreButton>
+            </>
+          )}
+          <div ref={scrollToBottomRef} />
         </StyledLogsSection>
       </>
     );
@@ -327,11 +517,12 @@ const StyledLogsSection = styled.div<{ isFullscreen: boolean }>`
   border-radius: ${(props) => (props.isFullscreen ? "" : "8px")};
   border: ${(props) => (props.isFullscreen ? "" : "1px solid #ffffff33")};
   border-top: ${(props) => (props.isFullscreen ? "1px solid #ffffff33" : "")};
-  padding: 18px 22px;
-  background: #121318;
+  background: #101420;
   animation: floatIn 0.3s;
   animation-timing-function: ease-out;
   animation-fill-mode: forwards;
+  overflow-y: auto;
+  overflow-wrap: break-word;
   @keyframes floatIn {
     from {
       opacity: 0;
@@ -343,3 +534,91 @@ const StyledLogsSection = styled.div<{ isFullscreen: boolean }>`
     }
   }
 `;
+
+const Log = styled.div`
+  font-family: monospace;
+  user-select: text;
+  display: flex;
+  align-items: flex-end;
+  gap: 8px;
+  width: 100%;
+  & > * {
+    padding-block: 5px;
+  }
+  & > .line-timestamp {
+    height: 100%;
+    color: #949effff;
+    opacity: 0.5;
+    font-family: monospace;
+    min-width: fit-content;
+    padding-inline-end: 5px;
+  }
+  & > .line-number {
+    height: 100%;
+    background: #202538;
+    display: inline-block;
+    text-align: right;
+    min-width: 45px;
+    padding-inline-end: 5px;
+    opacity: 0.3;
+    font-family: monospace;
+  }
+`;
+
+const LogSpan = styled.span`
+  display: inline-block;
+  word-wrap: anywhere;
+  flex-grow: 1;
+  font-family: monospace, sans-serif;
+  font-size: 12px;
+  font-weight: ${(props: { ansi: Anser.AnserJsonEntry }) =>
+    props.ansi?.decoration && props.ansi?.decoration == "bold" ? "700" : "400"};
+  color: ${(props: { ansi: Anser.AnserJsonEntry }) =>
+    props.ansi?.fg ? `rgb(${props.ansi?.fg})` : "white"};
+  background-color: ${(props: { ansi: Anser.AnserJsonEntry }) =>
+    props.ansi?.bg ? `rgb(${props.ansi?.bg})` : "transparent"};
+`;
+
+const LoadMoreButton = styled.div<{ active: boolean }>`
+  width: 100%;
+  display: ${(props) => (props.active ? "flex" : "none")};
+  justify-content: center;
+  align-items: center;
+  padding-block: 10px;
+  background: #1f2023;
+  cursor: pointer;
+  font-family: monospace;
+`;
+
+const ToggleOption = styled.div<{ selected: boolean; nudgeLeft?: boolean }>`
+  padding: 0 10px;
+  color: ${(props) => (props.selected ? "" : "#494b4f")};
+  border: 1px solid #494b4f;
+  height: 100%;
+  display: flex;
+  margin-left: ${(props) => (props.nudgeLeft ? "-1px" : "")};
+  align-items: center;
+  border-radius: ${(props) =>
+    props.nudgeLeft ? "0 5px 5px 0" : "5px 0 0 5px"};
+  :hover {
+    border: 1px solid #7a7b80;
+    z-index: 999;
+  }
+`;
+
+const ToggleButton = styled.div`
+  background: #26292e;
+  border-radius: 5px;
+  font-size: 13px;
+  height: 30px;
+  display: flex;
+  align-items: center;
+  cursor: pointer;
+`;
+
+const TimeIcon = styled.img<{ selected?: boolean }>`
+  width: 16px;
+  height: 16px;
+  z-index: 999;
+  opacity: ${(props) => (props.selected ? "" : "50%")};
+`;

+ 392 - 0
dashboard/src/main/home/cluster-dashboard/expanded-chart/logs-section/useAgentLogs.ts

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

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

@@ -299,7 +299,7 @@ const DeploymentList = () => {
             />
           );
         })} */}
-        {filteredDeployments.map((d) => {
+        {filteredDeployments.map((d: any) => {
           return (
             <DeploymentCard
               key={d.id}
@@ -473,10 +473,9 @@ const DeploymentList = () => {
             name="Sort"
           />
           <CreatePreviewEnvironmentButton
-            // TODO Justin: Implement Preview Environment
-            onClick={_.noop}
+            to={`/preview-environments/deployments/${environment_id}/${repo_owner}/${repo_name}/create`}
           >
-            <i className="material-icons">add</i> New preview env
+            <i className="material-icons">add</i> New preview deployment
           </CreatePreviewEnvironmentButton>
         </Flex>
       </FlexRow>
@@ -711,7 +710,7 @@ const FlexRow = styled.div`
   flex-wrap: wrap;
 `;
 
-const CreatePreviewEnvironmentButton = styled.div`
+const CreatePreviewEnvironmentButton = styled(DynamicLink)`
   display: flex;
   flex-direction: row;
   align-items: center;

+ 214 - 3
dashboard/src/main/home/cluster-dashboard/preview-environments/environments/CreateEnvironment.tsx

@@ -3,12 +3,31 @@ import Loading from "components/Loading";
 import React, { useContext, useEffect, useState } from "react";
 import api from "shared/api";
 import styled from "styled-components";
+import { CellProps } from "react-table";
 import { Context } from "shared/Context";
 import { useParams } from "react-router";
 import { PRDeployment } from "../types";
 import DashboardHeader from "../../DashboardHeader";
 import PullRequestIcon from "assets/pull_request_icon.svg";
 import Helper from "components/form-components/Helper";
+import Table from "components/Table";
+import pr_icon from "assets/pull_request_icon.svg";
+import { EllipsisTextWrapper, RepoLink } from "../components/styled";
+
+const dummyData: any = [
+ {
+  name: "this is a name",
+  branches: "asdf"
+ },
+ {
+  name: "this is a name",
+  branches: "asdf"
+ },
+ {
+  name: "this is a name",
+  branches: "asdf"
+ },
+];
 
 const CreateEnvironment: React.FC = () => {
   const { environment_id, repo_name, repo_owner } = useParams<{
@@ -18,6 +37,58 @@ const CreateEnvironment: React.FC = () => {
   }>();
 
   const selectedRepo = `${repo_owner}/${repo_name}`;
+
+  const columns = React.useMemo(
+    () => [
+      {
+        Header: "Monitors",
+        columns: [
+          {
+            Header: "Open pull requests",
+            accessor: "name",
+            width: 140,
+            Cell: ({ row }: CellProps<any>) => {
+              return (
+                <>
+                  <PRName>
+                    <PRIcon src={pr_icon} alt="pull request icon" />
+                    <EllipsisTextWrapper tooltipText="test">
+                      "test"
+                    </EllipsisTextWrapper>
+                    <Spacer />
+                    <RepoLink to="" target="_blank">
+                      <i className="material-icons">open_in_new</i>
+                      View last workflow
+                    </RepoLink>
+                  </PRName>
+
+                  <Flex>
+                    <DeploymentImageContainer>
+                      <InfoWrapper>
+                        <LastDeployed>
+                          Last updated xyz
+                        </LastDeployed>
+                      </InfoWrapper>
+                      <SepDot>•</SepDot>
+                      <MergeInfoWrapper>
+                      <MergeInfo
+                      >
+                        from-this-branch
+                        <i className="material-icons">arrow_forward</i>
+                        to-this-branch
+                      </MergeInfo>
+                    </MergeInfoWrapper>
+                    </DeploymentImageContainer>
+                  </Flex>
+                </>
+              );
+            },
+          },
+        ],
+      },
+    ],
+    []
+  );
  
   return (
     <>
@@ -42,13 +113,153 @@ const CreateEnvironment: React.FC = () => {
         capitalize={false}
       />
       <DarkMatter />
-      <Helper>Select an open pull request to preview.</Helper>
+      <Helper>Select an open pull request to preview. Pull requests must contain a <Code>porter.yaml</Code> file.</Helper>
+      <Br height="10px" />
+      <Table 
+        columns={columns} 
+        data={dummyData} 
+        placeholder="No open pull requests found."
+      />
+      <SubmitButton>Create preview deployment</SubmitButton>
     </>
   );
 };
 
 export default CreateEnvironment;
 
+const Code = styled.span`
+  font-family: monospace;
+;`
+
+const Spacer = styled.div`
+  width: 5px;
+`;
+
+const SepDot = styled.div`
+  color: #aaaabb66;
+`;
+
+const Flex = styled.div`
+  display: flex;
+  align-items: center;
+`;
+
+const DeploymentImageContainer = styled.div`
+  height: 20px;
+  font-size: 13px;
+  position: relative;
+  display: flex;
+  align-items: center;
+  font-weight: 400;
+  justify-content: center;
+  color: #ffffff66;
+  padding-left: 10px;
+`;
+
+const InfoWrapper = styled.div`
+  display: flex;
+  align-items: center;
+  white-space: nowrap;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  margin-right: 8px;
+  margin-left: 7px;
+`;
+
+const LastDeployed = styled.div`
+  font-size: 13px;
+  margin-top: -1px;
+  margin-left: 10px;
+  display: flex;
+  align-items: center;
+  color: #aaaabb66;
+`;
+
+const MergeInfoWrapper = styled.div`
+  display: flex;
+  align-items: center;
+  margin-right: 8px;
+  position: relative;
+  margin-left: 10px;
+`;
+
+const MergeInfo = styled.div`
+  font-size: 13px;
+  align-items: center;
+  color: #aaaabb66;
+  white-space: nowrap;
+  display: flex;
+  align-items: center;
+  text-overflow: ellipsis;
+  overflow: hidden;
+  max-width: 300px;
+
+  > i {
+    font-size: 16px;
+    margin: 0 2px;
+  }
+`;
+
+const PRIcon = styled.img`
+  font-size: 20px;
+  height: 17px;
+  margin-right: 10px;
+  color: #aaaabb;
+  opacity: 50%;
+`;
+
+const PRName = styled.div`
+  font-family: "Work Sans", sans-serif;
+  font-weight: 500;
+  color: #ffffff;
+  display: flex;
+  font-size: 14px;
+  align-items: center;
+  margin-bottom: 10px;
+`;
+
+const SubmitButton = styled.div`
+  display: flex;
+  flex-direction: row;
+  align-items: center;
+  justify-content: center;
+  font-size: 13px;
+  cursor: pointer;
+  font-family: "Work Sans", sans-serif;
+  border-radius: 5px;
+  font-weight: 500;
+  color: white;
+  height: 30px;
+  padding: 0 8px;
+  width: 200px;
+  margin-top: 30px;
+  overflow: hidden;
+  white-space: nowrap;
+  text-overflow: ellipsis;
+  cursor: ${(props: { disabled?: boolean }) =>
+    props.disabled ? "not-allowed" : "pointer"};
+
+  background: ${(props: { disabled?: boolean }) =>
+    props.disabled ? "#aaaabbee" : "#616FEEcc"};
+  :hover {
+    background: ${(props: { disabled?: boolean }) =>
+      props.disabled ? "" : "#505edddd"};
+  }
+
+  > i {
+    color: white;
+    width: 18px;
+    height: 18px;
+    font-weight: 600;
+    font-size: 12px;
+    border-radius: 20px;
+    display: flex;
+    align-items: center;
+    margin-right: 5px;
+    justify-content: center;
+  }
+`;
+
 const DarkMatter = styled.div`
   width: 100%;
   margin-top: -15px;
@@ -82,9 +293,9 @@ const DeleteButton = styled.div`
   }
 `;
 
-const Br = styled.div`
+const Br = styled.div<{ height: string }>`
   width: 100%;
-  height: 2px;
+  height: ${props => props.height || "2px"};
 `;
 
 const StyledPlaceholder = styled.div`

+ 16 - 16
dashboard/src/main/home/dashboard/ClusterList.tsx

@@ -75,47 +75,47 @@ class Templates extends Component<PropsType, StateType> {
           <path
             d="M15.207 12.4403C16.8094 12.4403 18.1092 11.1414 18.1092 9.53907C18.1092 7.93673 16.8094 6.63782 15.207 6.63782"
             stroke="white"
-            stroke-width="1.5"
-            stroke-linecap="round"
+            strokeWidth="1.5"
+            strokeLinecap="round"
             stroke-linejoin="round"
           />
           <path
             d="M3.90217 12.4403C2.29983 12.4403 1 11.1414 1 9.53907C1 7.93673 2.29983 6.63782 3.90217 6.63782"
             stroke="white"
-            stroke-width="1.5"
-            stroke-linecap="round"
+            strokeWidth="1.5"
+            strokeLinecap="round"
             stroke-linejoin="round"
           />
           <path
-            fill-rule="evenodd"
-            clip-rule="evenodd"
+            fillRule="evenodd"
+            clipRule="evenodd"
             d="M9.54993 13.4133C7.4086 13.4133 5.69168 11.6964 5.69168 9.55417C5.69168 7.41284 7.4086 5.69592 9.54993 5.69592C11.6913 5.69592 13.4082 7.41284 13.4082 9.55417C13.4082 11.6964 11.6913 13.4133 9.54993 13.4133Z"
             stroke="white"
-            stroke-width="1.5"
-            stroke-linecap="round"
+            strokeWidth="1.5"
+            strokeLinecap="round"
             stroke-linejoin="round"
           />
           <path
             d="M6.66895 15.207C6.66895 16.8094 7.96787 18.1092 9.5702 18.1092C11.1725 18.1092 12.4715 16.8094 12.4715 15.207"
             stroke="white"
-            stroke-width="1.5"
-            stroke-linecap="round"
+            strokeWidth="1.5"
+            strokeLinecap="round"
             stroke-linejoin="round"
           />
           <path
             d="M6.66895 3.90217C6.66895 2.29983 7.96787 1 9.5702 1C11.1725 1 12.4715 2.29983 12.4715 3.90217"
             stroke="white"
-            stroke-width="1.5"
-            stroke-linecap="round"
+            strokeWidth="1.5"
+            strokeLinecap="round"
             stroke-linejoin="round"
           />
           <path
-            fill-rule="evenodd"
-            clip-rule="evenodd"
+            fillRule="evenodd"
+            clipRule="evenodd"
             d="M5.69591 9.54996C5.69591 7.40863 7.41283 5.69171 9.55508 5.69171C11.6964 5.69171 13.4133 7.40863 13.4133 9.54996C13.4133 11.6913 11.6964 13.4082 9.55508 13.4082C7.41283 13.4082 5.69591 11.6913 5.69591 9.54996Z"
             stroke="white"
-            stroke-width="1.5"
-            stroke-linecap="round"
+            strokeWidth="1.5"
+            strokeLinecap="round"
             stroke-linejoin="round"
           />
         </svg>

+ 1 - 1
dashboard/src/main/home/infrastructure/InfrastructureList.tsx

@@ -7,7 +7,7 @@ import { pushFiltered } from "shared/routing";
 
 import { Column } from "react-table";
 import styled from "styled-components";
-import Table from "components/Table";
+import Table from "components/OldTable";
 
 import Loading from "components/Loading";
 

+ 1 - 1
dashboard/src/main/home/project-settings/APITokensSection.tsx

@@ -11,7 +11,7 @@ import Helper from "components/form-components/Helper";
 import Heading from "components/form-components/Heading";
 import CopyToClipboard from "components/CopyToClipboard";
 import { Column } from "react-table";
-import Table from "components/Table";
+import Table from "components/OldTable";
 import RadioSelector from "components/RadioSelector";
 import CreateAPITokenForm from "./api-tokens/CreateAPITokenForm";
 import TokenList from "./api-tokens/TokenList";

+ 1 - 1
dashboard/src/main/home/project-settings/InviteList.tsx

@@ -11,7 +11,7 @@ import Helper from "components/form-components/Helper";
 import Heading from "components/form-components/Heading";
 import CopyToClipboard from "components/CopyToClipboard";
 import { Column } from "react-table";
-import Table from "components/Table";
+import Table from "components/OldTable";
 import RadioSelector from "components/RadioSelector";
 
 type Props = {};

+ 1 - 1
dashboard/src/main/home/project-settings/api-tokens/CreateAPITokenForm.tsx

@@ -12,7 +12,7 @@ import Helper from "components/form-components/Helper";
 import Heading from "components/form-components/Heading";
 import CopyToClipboard from "components/CopyToClipboard";
 import { Column } from "react-table";
-import Table from "components/Table";
+import Table from "components/OldTable";
 import RadioSelector from "components/RadioSelector";
 import SelectRow from "components/form-components/SelectRow";
 import SaveButton from "components/SaveButton";

+ 1 - 1
dashboard/src/main/home/project-settings/api-tokens/CustomPolicyForm.tsx

@@ -12,7 +12,7 @@ import Helper from "components/form-components/Helper";
 import Heading from "components/form-components/Heading";
 import CopyToClipboard from "components/CopyToClipboard";
 import { Column } from "react-table";
-import Table from "components/Table";
+import Table from "components/OldTable";
 import RadioSelector from "components/RadioSelector";
 import SelectRow from "components/form-components/SelectRow";
 import SaveButton from "components/SaveButton";

+ 22 - 22
dashboard/src/main/home/sidebar/ClusterSection.tsx

@@ -170,48 +170,48 @@ export const ClusterSection: React.FC<Props> = ({
               <path
                 d="M15.207 12.4403C16.8094 12.4403 18.1092 11.1414 18.1092 9.53907C18.1092 7.93673 16.8094 6.63782 15.207 6.63782"
                 stroke="white"
-                stroke-width="1.5"
-                stroke-linecap="round"
-                stroke-linejoin="round"
+                strokeWidth="1.5"
+                strokeLinecap="round"
+                strokeLinejoin="round"
               />
               <path
                 d="M3.90217 12.4403C2.29983 12.4403 1 11.1414 1 9.53907C1 7.93673 2.29983 6.63782 3.90217 6.63782"
                 stroke="white"
-                stroke-width="1.5"
-                stroke-linecap="round"
-                stroke-linejoin="round"
+                strokeWidth="1.5"
+                strokeLinecap="round"
+                strokeLinejoin="round"
               />
               <path
-                fill-rule="evenodd"
-                clip-rule="evenodd"
+                fillRule="evenodd"
+                clipRule="evenodd"
                 d="M9.54993 13.4133C7.4086 13.4133 5.69168 11.6964 5.69168 9.55417C5.69168 7.41284 7.4086 5.69592 9.54993 5.69592C11.6913 5.69592 13.4082 7.41284 13.4082 9.55417C13.4082 11.6964 11.6913 13.4133 9.54993 13.4133Z"
                 stroke="white"
-                stroke-width="1.5"
-                stroke-linecap="round"
-                stroke-linejoin="round"
+                strokeWidth="1.5"
+                strokeLinecap="round"
+                strokeLinejoin="round"
               />
               <path
                 d="M6.66895 15.207C6.66895 16.8094 7.96787 18.1092 9.5702 18.1092C11.1725 18.1092 12.4715 16.8094 12.4715 15.207"
                 stroke="white"
-                stroke-width="1.5"
-                stroke-linecap="round"
-                stroke-linejoin="round"
+                strokeWidth="1.5"
+                strokeLinecap="round"
+                strokeLinejoin="round"
               />
               <path
                 d="M6.66895 3.90217C6.66895 2.29983 7.96787 1 9.5702 1C11.1725 1 12.4715 2.29983 12.4715 3.90217"
                 stroke="white"
-                stroke-width="1.5"
-                stroke-linecap="round"
-                stroke-linejoin="round"
+                strokeWidth="1.5"
+                strokeLinecap="round"
+                strokeLinejoin="round"
               />
               <path
-                fill-rule="evenodd"
-                clip-rule="evenodd"
+                fillRule="evenodd"
+                clipRule="evenodd"
                 d="M5.69591 9.54996C5.69591 7.40863 7.41283 5.69171 9.55508 5.69171C11.6964 5.69171 13.4133 7.40863 13.4133 9.54996C13.4133 11.6913 11.6964 13.4082 9.55508 13.4082C7.41283 13.4082 5.69591 11.6913 5.69591 9.54996Z"
                 stroke="white"
-                stroke-width="1.5"
-                stroke-linecap="round"
-                stroke-linejoin="round"
+                strokeWidth="1.5"
+                strokeLinecap="round"
+                strokeLinejoin="round"
               />
             </svg>
           </ClusterIcon>

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

@@ -97,10 +97,11 @@ const overwriteAWSIntegration = baseApi<
   return `/api/projects/${pathParams.project_id}/integrations/aws/overwrite`;
 });
 
-const updateClusterName = baseApi<
+const updateCluster = baseApi<
   {
-    name: string;
+    name?: string;
     aws_cluster_id?: string;
+    agent_integration_enabled?: boolean;
   },
   {
     project_id: number;
@@ -1821,61 +1822,6 @@ 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 }
@@ -2039,6 +1985,121 @@ const getGitlabFolderContent = baseApi<
     `/api/projects/${project_id}/integrations/gitlab/${integration_id}/repos/${repo_owner}/${repo_name}/${branch}/contents`
 );
 
+const getLogPodValues = baseApi<
+  {
+    revision?: string;
+    match_prefix?: string;
+    start_range?: string;
+    end_range?: string;
+  },
+  {
+    project_id: number;
+    cluster_id: number;
+  }
+>(
+  "GET",
+  ({ project_id, cluster_id }) =>
+    `/api/projects/${project_id}/clusters/${cluster_id}/logs/pod_values`
+);
+
+const getLogs = baseApi<
+  {
+    limit?: number;
+    start_range?: string;
+    end_range?: string;
+    revision?: string;
+    pod_selector: string;
+    namespace: string;
+    search_param?: string;
+    direction?: string;
+  },
+  {
+    project_id: number;
+    cluster_id: number;
+  }
+>(
+  "GET",
+  ({ project_id, cluster_id }) =>
+    `/api/projects/${project_id}/clusters/${cluster_id}/logs`
+);
+
+const listPorterEvents = baseApi<
+  {
+    release_name?: number;
+    release_namespace?: string;
+    type?: string;
+  },
+  {
+    project_id: number;
+    cluster_id: number;
+  }
+>(
+  "GET",
+  ({ project_id, cluster_id }) =>
+    `/api/projects/${project_id}/clusters/${cluster_id}/events`
+);
+
+const listPorterJobEvents = baseApi<
+  {
+    release_name?: number;
+    release_namespace?: string;
+    type?: string;
+    job_name: string;
+  },
+  {
+    project_id: number;
+    cluster_id: number;
+  }
+>(
+  "GET",
+  ({ project_id, cluster_id }) =>
+    `/api/projects/${project_id}/clusters/${cluster_id}/events/job`
+);
+
+const listIncidents = baseApi<
+  {
+    release_name?: number;
+    release_namespace?: string;
+    status?: string;
+  },
+  {
+    project_id: number;
+    cluster_id: number;
+  }
+>(
+  "GET",
+  ({ project_id, cluster_id }) =>
+    `/api/projects/${project_id}/clusters/${cluster_id}/incidents`
+);
+
+const getIncident = baseApi<
+  {},
+  {
+    project_id: number;
+    cluster_id: number;
+    incident_id: string;
+  }
+>(
+  "GET",
+  ({ project_id, cluster_id, incident_id }) =>
+    `/api/projects/${project_id}/clusters/${cluster_id}/incidents/${incident_id}`
+);
+
+const getIncidentEvents = baseApi<
+  {
+    incident_id?: string;
+    pod_prefix?: string;
+  },
+  {
+    project_id: number;
+    cluster_id: number;
+  }
+>(
+  "GET",
+  ({ project_id, cluster_id }) =>
+    `/api/projects/${project_id}/clusters/${cluster_id}/incidents/events`
+);
+
 // STACKS
 
 const createStack = baseApi<
@@ -2226,7 +2287,7 @@ export default {
   getGitlabIntegration,
   createAWSIntegration,
   overwriteAWSIntegration,
-  updateClusterName,
+  updateCluster,
   createAzureIntegration,
   createGitlabIntegration,
   createEmailVerification,
@@ -2386,10 +2447,6 @@ export default {
   provisionDatabase,
   getDatabases,
   getPreviousLogsForContainer,
-  getIncidents,
-  getIncidentsByReleaseName,
-  getIncidentById,
-  getIncidentLogsByLogId,
   upgradePorterAgent,
   deletePRDeployment,
   updateBuildConfig,
@@ -2403,7 +2460,13 @@ export default {
   getGitlabRepos,
   getGitlabBranches,
   getGitlabFolderContent,
-
+  getLogPodValues,
+  getLogs,
+  listPorterEvents,
+  listPorterJobEvents,
+  listIncidents,
+  getIncident,
+  getIncidentEvents,
   // STACKS
   listStacks,
   getStack,

+ 82 - 0
dashboard/src/shared/string_utils.ts

@@ -8,6 +8,88 @@ export const readableDate = (s: string) => {
   return `${time} on ${date}`;
 };
 
+export const relativeDate = (date: string | number) => {
+  if (!date) {
+    return "N/A";
+  }
+
+  // @ts-ignore
+  const rtf = new Intl.RelativeTimeFormat("en", {
+    localeMatcher: "best fit", // other values: "lookup"
+    numeric: "auto", // other values: "auto"
+    style: "long", // other values: "short" or "narrow"
+  });
+
+  const time = timeFrom(date);
+  if (!time) {
+    return "N/A";
+  }
+
+  return rtf.format(-time.time, time.unitOfTime);
+};
+
+export const timeFrom = (
+  time: string | number,
+  secondTime?: string | number
+) => {
+  // Get timestamps
+  let unixTime = new Date(time).getTime();
+  if (!unixTime) return;
+
+  let now = new Date().getTime();
+
+  if (secondTime) {
+    now = new Date(secondTime).getTime();
+  }
+
+  // Calculate difference
+  let difference = unixTime / 1000 - now / 1000;
+
+  // Setup return object
+  let tfn: any = {};
+
+  // Check if time is in the past, present, or future
+  tfn.when = "now";
+  if (difference > 0) {
+    tfn.when = "future";
+  } else if (difference < -1) {
+    tfn.when = "past";
+  }
+
+  // Convert difference to absolute
+  difference = Math.abs(difference);
+
+  // Calculate time unit
+  if (difference / (60 * 60 * 24 * 365) > 1) {
+    // Years
+    tfn.unitOfTime = "years";
+    tfn.time = Math.floor(difference / (60 * 60 * 24 * 365));
+  } else if (difference / (60 * 60 * 24 * 45) > 1) {
+    // Months
+    tfn.unitOfTime = "months";
+    tfn.time = Math.floor(difference / (60 * 60 * 24 * 45));
+  } else if (difference / (60 * 60 * 24) > 1) {
+    // Days
+    tfn.unitOfTime = "days";
+    tfn.time = Math.floor(difference / (60 * 60 * 24));
+  } else if (difference / (60 * 60) > 1) {
+    // Hours
+    tfn.unitOfTime = "hours";
+    tfn.time = Math.floor(difference / (60 * 60));
+  } else if (difference / 60 > 1) {
+    // Minutes
+    tfn.unitOfTime = "minutes";
+    tfn.time = Math.floor(difference / 60);
+  } else {
+    // Seconds
+    tfn.unitOfTime = "seconds";
+    tfn.time = Math.floor(difference);
+  }
+
+  // Return time from now data
+  return tfn;
+};
+
 export const capitalize = (s: string) => {
   return s.charAt(0).toUpperCase() + s.substring(1).toLowerCase();
 };

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

@@ -5,6 +5,7 @@ export interface ClusterType {
   name: string;
   server: string;
   service_account_id: number;
+  agent_integration_enabled: boolean;
   infra_id?: number;
   service?: string;
   aws_integration_id?: number;

+ 8 - 0
dashboard/src/shared/util.ts

@@ -0,0 +1,8 @@
+export const isJSON = (value: string): boolean => {
+  try {
+    JSON.parse(value);
+    return true;
+  } catch (err) {
+    return false;
+  }
+};

+ 11 - 4
dashboard/webpack.config.js

@@ -88,12 +88,19 @@ module.exports = () => {
           test: /\.(png|svg|jpg|gif|mp3)$/,
           use: ["file-loader"],
         },
+        // {
+        //   test: /\.css$/i,
+        //   loader: "css-loader",
+        //   options: {
+        //     import: true,
+        //   },
+        // },
         {
           test: /\.css$/i,
-          loader: "css-loader",
-          options: {
-            import: true,
-          },
+          use: [
+            { loader: "style-loader", options: { injectType: "linkTag" } },
+            { loader: "file-loader" },
+          ],
         },
         {
           test: /\.(woff(2)?|ttf|eot)(\?v=\d+\.\d+\.\d+)?$/,

+ 0 - 272
internal/integrations/slack/notifier.go

@@ -1,272 +0,0 @@
-package slack
-
-import (
-	"bytes"
-	"encoding/json"
-	"fmt"
-	"net/http"
-	"strings"
-	"time"
-
-	"github.com/porter-dev/porter/api/types"
-	"github.com/porter-dev/porter/internal/models/integrations"
-)
-
-type Notifier interface {
-	Notify(opts *NotifyOpts) error
-}
-
-type DeploymentStatus string
-
-const (
-	StatusHelmDeployed DeploymentStatus = "helm_deployed"
-	StatusPodCrashed   DeploymentStatus = "pod_crashed"
-	StatusHelmFailed   DeploymentStatus = "helm_failed"
-)
-
-type NotifyOpts struct {
-	// ProjectID is the id of the Porter project that this deployment belongs to
-	ProjectID uint
-
-	// ClusterID is the id of the Porter cluster that this deployment belongs to
-	ClusterID uint
-
-	// ClusterName is the name of the cluster that this deployment was deployed in
-	ClusterName string
-
-	// Status is the current status of the deployment.
-	Status DeploymentStatus
-
-	// Info is any additional information about this status, such as an error message if
-	// the deployment failed.
-	Info string
-
-	// Name is the name of the deployment that this notification refers to.
-	Name string
-
-	// Namespace is the Kubernetes namespace of the deployment that this notification refers to.
-	Namespace string
-
-	URL string
-
-	Timestamp *time.Time
-
-	Version int
-}
-
-type SlackNotifier struct {
-	slackInts []*integrations.SlackIntegration
-	Config    *types.NotificationConfig
-}
-
-func NewSlackNotifier(conf *types.NotificationConfig, slackInts ...*integrations.SlackIntegration) Notifier {
-	return &SlackNotifier{
-		slackInts: slackInts,
-		Config:    conf,
-	}
-}
-
-type SlackPayload struct {
-	Blocks []*SlackBlock `json:"blocks"`
-}
-
-type SlackBlock struct {
-	Type string     `json:"type"`
-	Text *SlackText `json:"text,omitempty"`
-}
-
-type SlackText struct {
-	Type string `json:"type"`
-	Text string `json:"text"`
-}
-
-func (s *SlackNotifier) Notify(opts *NotifyOpts) error {
-	if s.Config != nil {
-		if !s.Config.Enabled {
-			return nil
-		}
-		if opts.Status == StatusHelmDeployed && !s.Config.Success {
-			return nil
-		}
-		if opts.Status == StatusPodCrashed && !s.Config.Failure {
-			return nil
-		}
-		if opts.Status == StatusHelmFailed && !s.Config.Failure {
-			return nil
-		}
-	}
-
-	// we create a basic payload as a fallback if the detailed payload with "info" fails, due to
-	// marshaling errors on the Slack API side.
-	blocks, basicBlocks := getSlackBlocks(opts)
-
-	slackPayload := &SlackPayload{
-		Blocks: blocks,
-	}
-
-	basicSlackPayload := &SlackPayload{
-		Blocks: basicBlocks,
-	}
-
-	basicPayload, err := json.Marshal(basicSlackPayload)
-
-	if err != nil {
-		return err
-	}
-
-	payload, err := json.Marshal(slackPayload)
-
-	if err != nil {
-		return err
-	}
-
-	basicReqBody := bytes.NewReader(basicPayload)
-	reqBody := bytes.NewReader(payload)
-	client := &http.Client{
-		Timeout: time.Second * 5,
-	}
-
-	for _, slackInt := range s.slackInts {
-		resp, err := client.Post(string(slackInt.Webhook), "application/json", reqBody)
-
-		if err != nil || resp.StatusCode != 200 {
-			client.Post(string(slackInt.Webhook), "application/json", basicReqBody)
-		}
-	}
-
-	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.Timestamp != nil {
-		res = append(res, getMarkdownBlock(fmt.Sprintf(
-			"*Timestamp:* <!date^%d^Alerted at {date_num} {time_secs}|Alerted at %s>",
-			opts.Timestamp.Unix(),
-			opts.Timestamp.Format("2006-01-02 15:04:05 UTC"),
-		)),
-		)
-	}
-
-	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",
-	}
-}
-
-func getMarkdownBlock(md string) *SlackBlock {
-	return &SlackBlock{
-		Type: "section",
-		Text: &SlackText{
-			Type: "mrkdwn",
-			Text: md,
-		},
-	}
-}
-
-func getHelmMessageBlock(opts *NotifyOpts) *SlackBlock {
-	var md string
-
-	switch opts.Status {
-	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 StatusHelmFailed:
-		md = getFailedInfoMessage(opts)
-	case StatusPodCrashed:
-		md = getFailedInfoMessage(opts)
-	default:
-		return nil
-	}
-
-	return getMarkdownBlock(md)
-}
-
-func getHelmSuccessMessage(opts *NotifyOpts) string {
-	return fmt.Sprintf(
-		":rocket: Your application %s was successfully updated on Porter! <%s|View the new release.>",
-		"`"+opts.Name+"`",
-		opts.URL,
-	)
-}
-
-func getHelmFailedMessage(opts *NotifyOpts) string {
-	return fmt.Sprintf(
-		":x: Your application %s failed to deploy on Porter. <%s|View the status here.>",
-		"`"+opts.Name+"`",
-		opts.URL,
-	)
-}
-
-func getFailedInfoMessage(opts *NotifyOpts) string {
-	info := opts.Info
-
-	// TODO: this casing is quite ugly and looks for particular types of API server
-	// errors, otherwise it truncates the error message to 200 characters. This should
-	// handle the errors more gracefully.
-	if strings.Contains(info, "Invalid value:") {
-		errArr := strings.Split(info, "Invalid value:")
-
-		// look for "unmarshalerDecoder" error
-		if strings.Contains(info, "unmarshalerDecoder") {
-			udArr := strings.Split(info, "unmarshalerDecoder:")
-
-			info = errArr[0] + udArr[1]
-		} else {
-			info = errArr[0] + "..."
-		}
-	} else if len(info) > 200 {
-		info = info[0:200] + "..."
-	}
-
-	return fmt.Sprintf("```\n%s\n```", info)
-}

+ 141 - 0
internal/kubernetes/agent.go

@@ -46,6 +46,7 @@ import (
 	"k8s.io/client-go/rest"
 	"k8s.io/client-go/tools/cache"
 	"k8s.io/client-go/tools/remotecommand"
+	"k8s.io/kubectl/pkg/scheme"
 
 	rspb "helm.sh/helm/v3/pkg/release"
 )
@@ -1754,6 +1755,146 @@ func (a *Agent) StreamHelmReleases(namespace string, chartList []string, selecto
 	return a.RunWebsocketTask(run)
 }
 
+func (a *Agent) StreamPorterAgentLokiLog(
+	labels []string,
+	startTime string,
+	searchParam string,
+	limit uint32,
+	rw *websocket.WebsocketSafeReadWriter,
+) error {
+	run := func() error {
+		errorchan := make(chan error)
+
+		var wg sync.WaitGroup
+		var once sync.Once
+		var err error
+
+		wg.Add(2)
+
+		go func() {
+			wg.Wait()
+			close(errorchan)
+		}()
+
+		go func() {
+			defer func() {
+				if r := recover(); r != nil {
+					// TODO: add method to alert on panic
+					return
+				}
+			}()
+
+			defer wg.Done()
+
+			// listens for websocket closing handshake
+			for {
+				if _, _, err := rw.ReadMessage(); err != nil {
+					errorchan <- nil
+					return
+				}
+			}
+		}()
+
+		go func() {
+			defer func() {
+				if r := recover(); r != nil {
+					// TODO: add method to alert on panic
+					return
+				}
+			}()
+
+			defer wg.Done()
+
+			podList, err := a.Clientset.CoreV1().Pods("porter-agent-system").List(context.Background(), metav1.ListOptions{
+				LabelSelector: "control-plane=controller-manager",
+			})
+
+			if err != nil {
+				errorchan <- err
+				return
+			}
+
+			if len(podList.Items) == 0 {
+				errorchan <- fmt.Errorf("no porter agent pods found")
+				return
+			}
+
+			pod := podList.Items[0]
+
+			restConf, err := a.RESTClientGetter.ToRESTConfig()
+
+			if err != nil {
+				errorchan <- err
+				return
+			}
+
+			req := a.Clientset.CoreV1().RESTClient().
+				Post().
+				Resource("pods").
+				Name(pod.Name).
+				Namespace(pod.Namespace).
+				SubResource("exec")
+
+			cmd := []string{
+				"/porter/agent-cli",
+				"--start",
+				startTime,
+			}
+
+			for _, label := range labels {
+				cmd = append(cmd, "--label", label)
+			}
+
+			if searchParam != "" {
+				cmd = append(cmd, "--search", searchParam)
+			}
+
+			if limit > 0 {
+				cmd = append(cmd, "--limit", fmt.Sprintf("%d", limit))
+			}
+
+			opts := &v1.PodExecOptions{
+				Command: cmd,
+				Stdout:  true,
+				Stderr:  true,
+			}
+
+			req.VersionedParams(
+				opts,
+				scheme.ParameterCodec,
+			)
+
+			exec, err := remotecommand.NewSPDYExecutor(restConf, "POST", req.URL())
+
+			if err != nil {
+				errorchan <- err
+				return
+			}
+
+			err = exec.Stream(remotecommand.StreamOptions{
+				Stdin:  nil,
+				Stdout: rw,
+				Stderr: rw,
+			})
+
+			if err != nil {
+				errorchan <- err
+				return
+			}
+		}()
+
+		for err = range errorchan {
+			once.Do(func() {
+				rw.Close()
+			})
+		}
+
+		return err
+	}
+
+	return a.RunWebsocketTask(run)
+}
+
 // CreateImagePullSecrets will create the required image pull secrets and
 // return a map from the registry name to the name of the secret.
 func (a *Agent) CreateImagePullSecrets(

+ 11 - 0
internal/kubernetes/nodes/nodes.go

@@ -131,3 +131,14 @@ func DescribeNode(clientset kubernetes.Interface, nodeName string) *NodeDetails
 		AllocatableMemory: node.Status.Allocatable.Memory().String(),
 	}
 }
+
+func ListNodesByLabels(clientset kubernetes.Interface, labelSelector string) ([]v1.Node, error) {
+	nodes, err := clientset.CoreV1().Nodes().List(context.TODO(), metav1.ListOptions{
+		LabelSelector: labelSelector,
+	})
+	if err != nil {
+		return nil, err
+	}
+
+	return nodes.Items, nil
+}

+ 389 - 24
internal/kubernetes/porter_agent/v2/agent_server.go

@@ -5,6 +5,7 @@ import (
 	"encoding/json"
 	"fmt"
 
+	"github.com/porter-dev/porter/api/types"
 	v1 "k8s.io/api/core/v1"
 	"k8s.io/client-go/kubernetes"
 
@@ -20,16 +21,35 @@ func GetAgentService(clientset kubernetes.Interface) (*v1.Service, error) {
 	)
 }
 
-func GetAllIncidents(
+func ListPorterEvents(
 	clientset kubernetes.Interface,
 	service *v1.Service,
-) (*IncidentsResponse, error) {
+	req *types.ListEventsRequest,
+) (*types.ListEventsResponse, error) {
+	vals := make(map[string]string)
+
+	if req.Type != nil {
+		vals["type"] = *req.Type
+	}
+
+	if req.ReleaseName != nil {
+		vals["release_name"] = *req.ReleaseName
+	}
+
+	if req.ReleaseNamespace != nil {
+		vals["release_namespace"] = *req.ReleaseNamespace
+	}
+
+	if req.PaginationRequest != nil {
+		vals["page"] = fmt.Sprintf("%d", req.PaginationRequest.Page)
+	}
+
 	resp := clientset.CoreV1().Services(service.Namespace).ProxyGet(
 		"http",
 		service.Name,
 		fmt.Sprintf("%d", service.Spec.Ports[0].Port),
-		"/incidents",
-		nil,
+		"/events",
+		vals,
 	)
 
 	rawQuery, err := resp.DoRaw(context.Background())
@@ -37,27 +57,47 @@ func GetAllIncidents(
 		return nil, err
 	}
 
-	incidentsResp := &IncidentsResponse{}
+	eventsResp := &types.ListEventsResponse{}
 
-	err = json.Unmarshal(rawQuery, incidentsResp)
+	err = json.Unmarshal(rawQuery, eventsResp)
 	if err != nil {
 		return nil, err
 	}
 
-	return incidentsResp, nil
+	return eventsResp, nil
 }
 
-func GetIncidentEventsByID(
+func ListPorterJobEvents(
 	clientset kubernetes.Interface,
 	service *v1.Service,
-	incidentID string,
-) (*EventsResponse, error) {
+	req *types.ListJobEventsRequest,
+) (*types.ListEventsResponse, error) {
+	vals := make(map[string]string)
+
+	vals["job_name"] = req.JobName
+
+	if req.Type != nil {
+		vals["type"] = *req.Type
+	}
+
+	if req.ReleaseName != nil {
+		vals["release_name"] = *req.ReleaseName
+	}
+
+	if req.ReleaseNamespace != nil {
+		vals["release_namespace"] = *req.ReleaseNamespace
+	}
+
+	if req.PaginationRequest != nil {
+		vals["page"] = fmt.Sprintf("%d", req.PaginationRequest.Page)
+	}
+
 	resp := clientset.CoreV1().Services(service.Namespace).ProxyGet(
 		"http",
 		service.Name,
 		fmt.Sprintf("%d", service.Spec.Ports[0].Port),
-		fmt.Sprintf("/incidents/%s", incidentID),
-		nil,
+		"/events/job",
+		vals,
 	)
 
 	rawQuery, err := resp.DoRaw(context.Background())
@@ -65,7 +105,7 @@ func GetIncidentEventsByID(
 		return nil, err
 	}
 
-	eventsResp := &EventsResponse{}
+	eventsResp := &types.ListEventsResponse{}
 
 	err = json.Unmarshal(rawQuery, eventsResp)
 	if err != nil {
@@ -75,17 +115,35 @@ func GetIncidentEventsByID(
 	return eventsResp, nil
 }
 
-func GetIncidentsByReleaseNamespace(
+func ListIncidents(
 	clientset kubernetes.Interface,
 	service *v1.Service,
-	releaseName, namespace string,
-) (*IncidentsResponse, error) {
+	req *types.ListIncidentsRequest,
+) (*types.ListIncidentsResponse, error) {
+	vals := make(map[string]string)
+
+	if req.Status != nil {
+		vals["status"] = string(*req.Status)
+	}
+
+	if req.ReleaseName != nil {
+		vals["release_name"] = *req.ReleaseName
+	}
+
+	if req.ReleaseNamespace != nil {
+		vals["release_namespace"] = *req.ReleaseNamespace
+	}
+
+	if req.PaginationRequest != nil {
+		vals["page"] = fmt.Sprintf("%d", req.PaginationRequest.Page)
+	}
+
 	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,
+		"/incidents",
+		vals,
 	)
 
 	rawQuery, err := resp.DoRaw(context.Background())
@@ -93,7 +151,7 @@ func GetIncidentsByReleaseNamespace(
 		return nil, err
 	}
 
-	incidentsResp := &IncidentsResponse{}
+	incidentsResp := &types.ListIncidentsResponse{}
 
 	err = json.Unmarshal(rawQuery, incidentsResp)
 	if err != nil {
@@ -103,16 +161,16 @@ func GetIncidentsByReleaseNamespace(
 	return incidentsResp, nil
 }
 
-func GetLogs(
+func GetIncidentByID(
 	clientset kubernetes.Interface,
 	service *v1.Service,
-	logID string,
-) (*LogsResponse, error) {
+	incidentID string,
+) (*types.Incident, 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),
+		fmt.Sprintf("/incidents/%s", incidentID),
 		nil,
 	)
 
@@ -121,7 +179,125 @@ func GetLogs(
 		return nil, err
 	}
 
-	logsResp := &LogsResponse{}
+	incident := &types.Incident{}
+
+	if err := json.Unmarshal(rawQuery, incident); err != nil {
+		return nil, err
+	}
+
+	return incident, nil
+}
+
+func ListIncidentEvents(
+	clientset kubernetes.Interface,
+	service *v1.Service,
+	req *types.ListIncidentEventsRequest,
+) (*types.ListIncidentEventsResponse, error) {
+	vals := make(map[string]string)
+
+	if req.IncidentID != nil {
+		vals["incident_id"] = *req.IncidentID
+	}
+
+	if req.PodName != nil {
+		vals["pod_name"] = *req.PodName
+	}
+
+	if req.PodNamespace != nil {
+		vals["pod_namespace"] = *req.PodNamespace
+	}
+
+	if req.Summary != nil {
+		vals["summary"] = *req.Summary
+	}
+
+	if req.PaginationRequest != nil {
+		vals["page"] = fmt.Sprintf("%d", req.PaginationRequest.Page)
+	}
+
+	if req.PodPrefix != nil {
+		vals["pod_prefix"] = *req.PodPrefix
+	}
+
+	resp := clientset.CoreV1().Services(service.Namespace).ProxyGet(
+		"http",
+		service.Name,
+		fmt.Sprintf("%d", service.Spec.Ports[0].Port),
+		"/incidents/events",
+		vals,
+	)
+
+	rawQuery, err := resp.DoRaw(context.Background())
+	if err != nil {
+		return nil, err
+	}
+
+	events := &types.ListIncidentEventsResponse{}
+
+	if err := json.Unmarshal(rawQuery, events); err != nil {
+		return nil, err
+	}
+
+	return events, nil
+}
+
+func GetHistoricalLogs(
+	clientset kubernetes.Interface,
+	service *v1.Service,
+	req *types.GetLogRequest,
+) (*types.GetLogResponse, error) {
+	vals := make(map[string]string)
+
+	if req.Limit != 0 {
+		vals["limit"] = fmt.Sprintf("%d", req.Limit)
+	}
+
+	if req.StartRange != nil {
+		startVal, err := req.StartRange.MarshalText()
+
+		if err != nil {
+			return nil, err
+		}
+
+		vals["start_range"] = string(startVal)
+	}
+
+	if req.EndRange != nil {
+		endVal, err := req.EndRange.MarshalText()
+
+		if err != nil {
+			return nil, err
+		}
+
+		vals["end_range"] = string(endVal)
+	}
+
+	vals["pod_selector"] = req.PodSelector
+	vals["namespace"] = req.Namespace
+	vals["revision"] = req.Revision
+
+	if req.SearchParam != "" {
+		vals["search_param"] = req.SearchParam
+	}
+
+	if req.Direction != "" {
+		vals["direction"] = req.Direction
+	}
+
+	resp := clientset.CoreV1().Services(service.Namespace).ProxyGet(
+		"http",
+		service.Name,
+		fmt.Sprintf("%d", service.Spec.Ports[0].Port),
+		"/logs",
+		vals,
+	)
+
+	rawQuery, err := resp.DoRaw(context.Background())
+	if err != nil {
+		return nil, err
+	}
+
+	logsResp := &types.GetLogResponse{}
 
 	err = json.Unmarshal(rawQuery, logsResp)
 	if err != nil {
@@ -130,3 +306,192 @@ func GetLogs(
 
 	return logsResp, nil
 }
+
+func GetPodValues(
+	clientset kubernetes.Interface,
+	service *v1.Service,
+	req *types.GetPodValuesRequest,
+) ([]string, error) {
+	vals := make(map[string]string)
+
+	if req.StartRange != nil {
+		startVal, err := req.StartRange.MarshalText()
+
+		if err != nil {
+			return nil, err
+		}
+
+		vals["start_range"] = string(startVal)
+	}
+
+	if req.EndRange != nil {
+		endVal, err := req.EndRange.MarshalText()
+
+		if err != nil {
+			return nil, err
+		}
+
+		vals["end_range"] = string(endVal)
+	}
+
+	vals["match_prefix"] = req.MatchPrefix
+	vals["revision"] = req.Revision
+
+	resp := clientset.CoreV1().Services(service.Namespace).ProxyGet(
+		"http",
+		service.Name,
+		fmt.Sprintf("%d", service.Spec.Ports[0].Port),
+		"/logs/pod_values",
+		vals,
+	)
+
+	rawQuery, err := resp.DoRaw(context.Background())
+	if err != nil {
+		return nil, err
+	}
+
+	valsResp := make([]string, 0)
+
+	err = json.Unmarshal(rawQuery, &valsResp)
+	if err != nil {
+		return nil, err
+	}
+
+	return valsResp, nil
+}
+
+func GetRevisionValues(
+	clientset kubernetes.Interface,
+	service *v1.Service,
+	req *types.GetRevisionValuesRequest,
+) ([]string, error) {
+	vals := make(map[string]string)
+
+	if req.StartRange != nil {
+		startVal, err := req.StartRange.MarshalText()
+
+		if err != nil {
+			return nil, err
+		}
+
+		vals["start_range"] = string(startVal)
+	}
+
+	if req.EndRange != nil {
+		endVal, err := req.EndRange.MarshalText()
+
+		if err != nil {
+			return nil, err
+		}
+
+		vals["end_range"] = string(endVal)
+	}
+
+	vals["match_prefix"] = req.MatchPrefix
+
+	resp := clientset.CoreV1().Services(service.Namespace).ProxyGet(
+		"http",
+		service.Name,
+		fmt.Sprintf("%d", service.Spec.Ports[0].Port),
+		"/logs/revision_values",
+		vals,
+	)
+
+	rawQuery, err := resp.DoRaw(context.Background())
+	if err != nil {
+		return nil, err
+	}
+
+	valsResp := make([]string, 0)
+
+	err = json.Unmarshal(rawQuery, &valsResp)
+	if err != nil {
+		return nil, err
+	}
+
+	return valsResp, nil
+}
+
+func GetHistoricalKubernetesEvents(
+	clientset kubernetes.Interface,
+	service *v1.Service,
+	req *types.GetKubernetesEventRequest,
+) (*types.GetKubernetesEventResponse, error) {
+	vals := make(map[string]string)
+
+	if req.Limit != 0 {
+		vals["limit"] = fmt.Sprintf("%d", req.Limit)
+	}
+
+	if req.StartRange != nil {
+		startVal, err := req.StartRange.MarshalText()
+
+		if err != nil {
+			return nil, err
+		}
+
+		vals["start_range"] = string(startVal)
+	}
+
+	if req.EndRange != nil {
+		endVal, err := req.EndRange.MarshalText()
+
+		if err != nil {
+			return nil, err
+		}
+
+		vals["end_range"] = string(endVal)
+	}
+
+	vals["pod_selector"] = req.PodSelector
+	vals["namespace"] = req.Namespace
+
+	resp := clientset.CoreV1().Services(service.Namespace).ProxyGet(
+		"http",
+		service.Name,
+		fmt.Sprintf("%d", service.Spec.Ports[0].Port),
+		"/events",
+		vals,
+	)
+
+	rawQuery, err := resp.DoRaw(context.Background())
+	if err != nil {
+		return nil, err
+	}
+
+	eventsResp := &types.GetKubernetesEventResponse{}
+
+	err = json.Unmarshal(rawQuery, eventsResp)
+	if err != nil {
+		return nil, err
+	}
+
+	return eventsResp, nil
+}
+
+func GetAgentStatus(
+	clientset kubernetes.Interface,
+	service *v1.Service,
+) (*types.GetAgentStatusResponse, error) {
+	resp := clientset.CoreV1().Services(service.Namespace).ProxyGet(
+		"http",
+		service.Name,
+		fmt.Sprintf("%d", service.Spec.Ports[0].Port),
+		"/status",
+		nil,
+	)
+
+	rawQuery, err := resp.DoRaw(context.Background())
+	if err != nil {
+		return nil, err
+	}
+
+	statusResp := &types.GetAgentStatusResponse{}
+
+	err = json.Unmarshal(rawQuery, statusResp)
+	if err != nil {
+		return nil, err
+	}
+
+	return statusResp, nil
+}

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

@@ -1,55 +0,0 @@
-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"`
-}

+ 12 - 8
internal/models/cluster.go

@@ -36,6 +36,9 @@ type Cluster struct {
 	// The project that this integration belongs to
 	ProjectID uint `json:"project_id"`
 
+	// Whether or not the Porter agent integration is enabled on the cluster
+	AgentIntegrationEnabled bool
+
 	// Name of the cluster
 	Name string `json:"name"`
 
@@ -95,14 +98,15 @@ func (c *Cluster) ToClusterType() *types.Cluster {
 	}
 
 	return &types.Cluster{
-		ID:               c.ID,
-		ProjectID:        c.ProjectID,
-		Name:             c.Name,
-		Server:           c.Server,
-		Service:          serv,
-		InfraID:          c.InfraID,
-		AWSIntegrationID: c.AWSIntegrationID,
-		AWSClusterID:     c.AWSClusterID,
+		ID:                      c.ID,
+		ProjectID:               c.ProjectID,
+		Name:                    c.Name,
+		Server:                  c.Server,
+		Service:                 serv,
+		AgentIntegrationEnabled: c.AgentIntegrationEnabled,
+		InfraID:                 c.InfraID,
+		AWSIntegrationID:        c.AWSIntegrationID,
+		AWSClusterID:            c.AWSClusterID,
 	}
 }
 

+ 45 - 0
internal/notifier/deployment_notifier.go

@@ -0,0 +1,45 @@
+package notifier
+
+import "time"
+
+type Notifier interface {
+	Notify(opts *NotifyOpts) error
+}
+
+type DeploymentStatus string
+
+const (
+	StatusHelmDeployed DeploymentStatus = "helm_deployed"
+	StatusPodCrashed   DeploymentStatus = "pod_crashed"
+	StatusHelmFailed   DeploymentStatus = "helm_failed"
+)
+
+type NotifyOpts struct {
+	// ProjectID is the id of the Porter project that this deployment belongs to
+	ProjectID uint
+
+	// ClusterID is the id of the Porter cluster that this deployment belongs to
+	ClusterID uint
+
+	// ClusterName is the name of the cluster that this deployment was deployed in
+	ClusterName string
+
+	// Status is the current status of the deployment.
+	Status DeploymentStatus
+
+	// Info is any additional information about this status, such as an error message if
+	// the deployment failed.
+	Info string
+
+	// Name is the name of the deployment that this notification refers to.
+	Name string
+
+	// Namespace is the Kubernetes namespace of the deployment that this notification refers to.
+	Namespace string
+
+	URL string
+
+	Timestamp *time.Time
+
+	Version int
+}

+ 49 - 0
internal/notifier/incident_notifier.go

@@ -0,0 +1,49 @@
+package notifier
+
+import "github.com/porter-dev/porter/api/types"
+
+type IncidentNotifier interface {
+	NotifyNew(incident *types.Incident, url string) error
+	NotifyResolved(incident *types.Incident, url string) error
+}
+
+type MultiIncidentNotifier struct {
+	notifConf *types.NotificationConfig
+	notifiers []IncidentNotifier
+}
+
+func NewMultiIncidentNotifier(notifConf *types.NotificationConfig, notifiers ...IncidentNotifier) IncidentNotifier {
+	return &MultiIncidentNotifier{notifConf, notifiers}
+}
+
+func (m *MultiIncidentNotifier) NotifyNew(incident *types.Incident, url string) error {
+	// if notification config exists and notifs are disabled for this release, or failure notifications
+	// are disabled, do not alert
+	if m.notifConf != nil && (!m.notifConf.Enabled || !m.notifConf.Failure) {
+		return nil
+	}
+
+	for _, n := range m.notifiers {
+		if err := n.NotifyNew(incident, url); err != nil {
+			return err
+		}
+	}
+
+	return nil
+}
+
+func (m *MultiIncidentNotifier) NotifyResolved(incident *types.Incident, url string) error {
+	// if notification config exists and notifs are disabled for this release, or failure notifications
+	// are disabled, do not alert
+	if m.notifConf != nil && (!m.notifConf.Enabled || !m.notifConf.Failure) {
+		return nil
+	}
+
+	for _, n := range m.notifiers {
+		if err := n.NotifyResolved(incident, url); err != nil {
+			return err
+		}
+	}
+
+	return nil
+}

+ 6 - 0
internal/notifier/sendgrid/client.go

@@ -0,0 +1,6 @@
+package sendgrid
+
+type SharedOpts struct {
+	APIKey      string
+	SenderEmail string
+}

+ 108 - 0
internal/notifier/sendgrid/incident_notifier.go

@@ -0,0 +1,108 @@
+package sendgrid
+
+import (
+	"fmt"
+
+	"github.com/porter-dev/porter/api/types"
+	"github.com/porter-dev/porter/internal/models"
+	"github.com/porter-dev/porter/internal/notifier"
+	"github.com/sendgrid/sendgrid-go"
+	"github.com/sendgrid/sendgrid-go/helpers/mail"
+)
+
+type IncidentNotifier struct {
+	opts *IncidentNotifierOpts
+}
+
+type IncidentNotifierOpts struct {
+	*SharedOpts
+	IncidentAlertTemplateID    string
+	IncidentResolvedTemplateID string
+	Users                      []*models.User
+}
+
+func NewIncidentNotifier(opts *IncidentNotifierOpts) notifier.IncidentNotifier {
+	return &IncidentNotifier{opts}
+}
+
+func (s *IncidentNotifier) NotifyNew(incident *types.Incident, url string) error {
+	request := sendgrid.GetRequest(s.opts.APIKey, "/v3/mail/send", "https://api.sendgrid.com")
+	request.Method = "POST"
+
+	personalizations := make([]*mail.Personalization, 0)
+
+	templData := map[string]interface{}{
+		"incident_text": incident.Summary,
+		"app_url":       url,
+		"subject":       fmt.Sprintf("Your application %s crashed on Porter", incident.ReleaseName),
+		"preheader":     incident.Summary,
+		"created_at":    fmt.Sprintf("%s", incident.CreatedAt.Format("Jan 2, 2006 at 3:04pm (MST)")),
+	}
+
+	for _, user := range s.opts.Users {
+		personalizations = append(personalizations, &mail.Personalization{
+			To: []*mail.Email{
+				{
+					Address: user.Email,
+				},
+			},
+			DynamicTemplateData: templData,
+		})
+	}
+
+	sgMail := &mail.SGMailV3{
+		Personalizations: personalizations,
+		From: &mail.Email{
+			Address: s.opts.SenderEmail,
+			Name:    "Porter Notifications",
+		},
+		TemplateID: s.opts.IncidentAlertTemplateID,
+	}
+
+	request.Body = mail.GetRequestBody(sgMail)
+
+	_, err := sendgrid.API(request)
+
+	return err
+}
+
+func (s *IncidentNotifier) NotifyResolved(incident *types.Incident, url string) error {
+	request := sendgrid.GetRequest(s.opts.APIKey, "/v3/mail/send", "https://api.sendgrid.com")
+	request.Method = "POST"
+
+	personalizations := make([]*mail.Personalization, 0)
+
+	templData := map[string]interface{}{
+		"incident_resolved_text": fmt.Sprintf("[Resolved] The incident for application %s has been resolved. The incident text was:\n\n:%s", incident.ReleaseName, incident.Summary),
+		"app_url":                url,
+		"subject":                fmt.Sprintf("[Resolved] The incident for application %s has been resolved", incident.ReleaseName),
+		"preheader":              incident.Summary,
+		"resolved_at":            fmt.Sprintf("%s", incident.UpdatedAt.Format("Jan 2, 2006 at 3:04pm (MST)")),
+	}
+
+	for _, user := range s.opts.Users {
+		personalizations = append(personalizations, &mail.Personalization{
+			To: []*mail.Email{
+				{
+					Address: user.Email,
+				},
+			},
+			DynamicTemplateData: templData,
+		})
+	}
+
+	sgMail := &mail.SGMailV3{
+		Personalizations: personalizations,
+		From: &mail.Email{
+			Address: s.opts.SenderEmail,
+			Name:    "Porter Notifications",
+		},
+		TemplateID: s.opts.IncidentResolvedTemplateID,
+	}
+
+	request.Body = mail.GetRequestBody(sgMail)
+
+	_, err := sendgrid.API(request)
+
+	return err
+}

+ 17 - 18
internal/notifier/sendgrid/sendgrid.go → internal/notifier/sendgrid/user_notifier.go

@@ -7,24 +7,23 @@ import (
 )
 
 type UserNotifier struct {
-	client *Client
+	opts *UserNotifierOpts
 }
 
-type Client struct {
-	APIKey                  string
+type UserNotifierOpts struct {
+	*SharedOpts
 	PWResetTemplateID       string
 	PWGHTemplateID          string
 	VerifyEmailTemplateID   string
 	ProjectInviteTemplateID string
-	SenderEmail             string
 }
 
-func NewUserNotifier(client *Client) notifier.UserNotifier {
-	return &UserNotifier{client}
+func NewUserNotifier(opts *UserNotifierOpts) notifier.UserNotifier {
+	return &UserNotifier{opts}
 }
 
 func (s *UserNotifier) SendPasswordResetEmail(opts *notifier.SendPasswordResetEmailOpts) error {
-	request := sendgrid.GetRequest(s.client.APIKey, "/v3/mail/send", "https://api.sendgrid.com")
+	request := sendgrid.GetRequest(s.opts.APIKey, "/v3/mail/send", "https://api.sendgrid.com")
 	request.Method = "POST"
 
 	sgMail := &mail.SGMailV3{
@@ -42,10 +41,10 @@ func (s *UserNotifier) SendPasswordResetEmail(opts *notifier.SendPasswordResetEm
 			},
 		},
 		From: &mail.Email{
-			Address: s.client.SenderEmail,
+			Address: s.opts.SenderEmail,
 			Name:    "Porter",
 		},
-		TemplateID: s.client.PWResetTemplateID,
+		TemplateID: s.opts.PWResetTemplateID,
 	}
 
 	request.Body = mail.GetRequestBody(sgMail)
@@ -56,7 +55,7 @@ func (s *UserNotifier) SendPasswordResetEmail(opts *notifier.SendPasswordResetEm
 }
 
 func (s *UserNotifier) SendGithubRelinkEmail(opts *notifier.SendGithubRelinkEmailOpts) error {
-	request := sendgrid.GetRequest(s.client.APIKey, "/v3/mail/send", "https://api.sendgrid.com")
+	request := sendgrid.GetRequest(s.opts.APIKey, "/v3/mail/send", "https://api.sendgrid.com")
 	request.Method = "POST"
 
 	sgMail := &mail.SGMailV3{
@@ -74,10 +73,10 @@ func (s *UserNotifier) SendGithubRelinkEmail(opts *notifier.SendGithubRelinkEmai
 			},
 		},
 		From: &mail.Email{
-			Address: s.client.SenderEmail,
+			Address: s.opts.SenderEmail,
 			Name:    "Porter",
 		},
-		TemplateID: s.client.PWGHTemplateID,
+		TemplateID: s.opts.PWGHTemplateID,
 	}
 
 	request.Body = mail.GetRequestBody(sgMail)
@@ -88,7 +87,7 @@ func (s *UserNotifier) SendGithubRelinkEmail(opts *notifier.SendGithubRelinkEmai
 }
 
 func (s *UserNotifier) SendEmailVerification(opts *notifier.SendEmailVerificationOpts) error {
-	request := sendgrid.GetRequest(s.client.APIKey, "/v3/mail/send", "https://api.sendgrid.com")
+	request := sendgrid.GetRequest(s.opts.APIKey, "/v3/mail/send", "https://api.sendgrid.com")
 	request.Method = "POST"
 
 	sgMail := &mail.SGMailV3{
@@ -106,10 +105,10 @@ func (s *UserNotifier) SendEmailVerification(opts *notifier.SendEmailVerificatio
 			},
 		},
 		From: &mail.Email{
-			Address: s.client.SenderEmail,
+			Address: s.opts.SenderEmail,
 			Name:    "Porter",
 		},
-		TemplateID: s.client.VerifyEmailTemplateID,
+		TemplateID: s.opts.VerifyEmailTemplateID,
 	}
 
 	request.Body = mail.GetRequestBody(sgMail)
@@ -120,7 +119,7 @@ func (s *UserNotifier) SendEmailVerification(opts *notifier.SendEmailVerificatio
 }
 
 func (s *UserNotifier) SendProjectInviteEmail(opts *notifier.SendProjectInviteEmailOpts) error {
-	request := sendgrid.GetRequest(s.client.APIKey, "/v3/mail/send", "https://api.sendgrid.com")
+	request := sendgrid.GetRequest(s.opts.APIKey, "/v3/mail/send", "https://api.sendgrid.com")
 	request.Method = "POST"
 
 	sgMail := &mail.SGMailV3{
@@ -139,10 +138,10 @@ func (s *UserNotifier) SendProjectInviteEmail(opts *notifier.SendProjectInviteEm
 			},
 		},
 		From: &mail.Email{
-			Address: s.client.SenderEmail,
+			Address: s.opts.SenderEmail,
 			Name:    "Porter",
 		},
-		TemplateID: s.client.ProjectInviteTemplateID,
+		TemplateID: s.opts.ProjectInviteTemplateID,
 	}
 
 	request.Body = mail.GetRequestBody(sgMail)

+ 95 - 0
internal/notifier/slack/deployment_notifier.go

@@ -0,0 +1,95 @@
+package slack
+
+import (
+	"bytes"
+	"encoding/json"
+	"net/http"
+	"time"
+
+	"github.com/porter-dev/porter/api/types"
+	"github.com/porter-dev/porter/internal/models/integrations"
+	"github.com/porter-dev/porter/internal/notifier"
+)
+
+type DeploymentNotifier struct {
+	slackInts []*integrations.SlackIntegration
+	Config    *types.NotificationConfig
+}
+
+func NewDeploymentNotifier(conf *types.NotificationConfig, slackInts ...*integrations.SlackIntegration) *DeploymentNotifier {
+	return &DeploymentNotifier{
+		slackInts: slackInts,
+		Config:    conf,
+	}
+}
+
+type SlackPayload struct {
+	Blocks []*SlackBlock `json:"blocks"`
+}
+
+type SlackBlock struct {
+	Type string     `json:"type"`
+	Text *SlackText `json:"text,omitempty"`
+}
+
+type SlackText struct {
+	Type string `json:"type"`
+	Text string `json:"text"`
+}
+
+func (s *DeploymentNotifier) Notify(opts *notifier.NotifyOpts) error {
+	if s.Config != nil {
+		if !s.Config.Enabled {
+			return nil
+		}
+		if opts.Status == notifier.StatusHelmDeployed && !s.Config.Success {
+			return nil
+		}
+		if opts.Status == notifier.StatusPodCrashed && !s.Config.Failure {
+			return nil
+		}
+		if opts.Status == notifier.StatusHelmFailed && !s.Config.Failure {
+			return nil
+		}
+	}
+
+	// we create a basic payload as a fallback if the detailed payload with "info" fails, due to
+	// marshaling errors on the Slack API side.
+	blocks, basicBlocks := getSlackBlocks(opts)
+
+	slackPayload := &SlackPayload{
+		Blocks: blocks,
+	}
+
+	basicSlackPayload := &SlackPayload{
+		Blocks: basicBlocks,
+	}
+
+	basicPayload, err := json.Marshal(basicSlackPayload)
+
+	if err != nil {
+		return err
+	}
+
+	payload, err := json.Marshal(slackPayload)
+
+	if err != nil {
+		return err
+	}
+
+	basicReqBody := bytes.NewReader(basicPayload)
+	reqBody := bytes.NewReader(payload)
+	client := &http.Client{
+		Timeout: time.Second * 5,
+	}
+
+	for _, slackInt := range s.slackInts {
+		resp, err := client.Post(string(slackInt.Webhook), "application/json", reqBody)
+
+		if err != nil || resp.StatusCode != 200 {
+			client.Post(string(slackInt.Webhook), "application/json", basicReqBody)
+		}
+	}
+
+	return nil
+}

+ 142 - 0
internal/notifier/slack/helpers.go

@@ -0,0 +1,142 @@
+package slack
+
+import (
+	"fmt"
+	"strings"
+
+	"github.com/porter-dev/porter/internal/notifier"
+)
+
+func getSlackBlocks(opts *notifier.NotifyOpts) ([]*SlackBlock, []*SlackBlock) {
+	res := []*SlackBlock{}
+
+	if opts.Status == notifier.StatusHelmDeployed || opts.Status == notifier.StatusHelmFailed {
+		res = append(res, getHelmMessageBlock(opts))
+	} else if opts.Status == notifier.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.Timestamp != nil {
+		res = append(res, getMarkdownBlock(fmt.Sprintf(
+			"*Timestamp:* <!date^%d^Alerted at {date_num} {time_secs}|Alerted at %s>",
+			opts.Timestamp.Unix(),
+			opts.Timestamp.Format("2006-01-02 15:04:05 UTC"),
+		)),
+		)
+	}
+
+	if opts.Status == notifier.StatusHelmDeployed || opts.Status == notifier.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",
+	}
+}
+
+func getMarkdownBlock(md string) *SlackBlock {
+	return &SlackBlock{
+		Type: "section",
+		Text: &SlackText{
+			Type: "mrkdwn",
+			Text: md,
+		},
+	}
+}
+
+func getHelmMessageBlock(opts *notifier.NotifyOpts) *SlackBlock {
+	var md string
+
+	switch opts.Status {
+	case notifier.StatusHelmDeployed:
+		md = getHelmSuccessMessage(opts)
+	case notifier.StatusHelmFailed:
+		md = getHelmFailedMessage(opts)
+	}
+
+	return getMarkdownBlock(md)
+}
+
+func getPodCrashedMessageBlock(opts *notifier.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 *notifier.NotifyOpts) *SlackBlock {
+	var md string
+
+	switch opts.Status {
+	case notifier.StatusHelmFailed:
+		md = getFailedInfoMessage(opts)
+	case notifier.StatusPodCrashed:
+		md = getFailedInfoMessage(opts)
+	default:
+		return nil
+	}
+
+	return getMarkdownBlock(md)
+}
+
+func getHelmSuccessMessage(opts *notifier.NotifyOpts) string {
+	return fmt.Sprintf(
+		":rocket: Your application %s was successfully updated on Porter! <%s|View the new release.>",
+		"`"+opts.Name+"`",
+		opts.URL,
+	)
+}
+
+func getHelmFailedMessage(opts *notifier.NotifyOpts) string {
+	return fmt.Sprintf(
+		":x: Your application %s failed to deploy on Porter. <%s|View the status here.>",
+		"`"+opts.Name+"`",
+		opts.URL,
+	)
+}
+
+func getFailedInfoMessage(opts *notifier.NotifyOpts) string {
+	info := opts.Info
+
+	// TODO: this casing is quite ugly and looks for particular types of API server
+	// errors, otherwise it truncates the error message to 200 characters. This should
+	// handle the errors more gracefully.
+	if strings.Contains(info, "Invalid value:") {
+		errArr := strings.Split(info, "Invalid value:")
+
+		// look for "unmarshalerDecoder" error
+		if strings.Contains(info, "unmarshalerDecoder") {
+			udArr := strings.Split(info, "unmarshalerDecoder:")
+
+			info = errArr[0] + udArr[1]
+		} else {
+			info = errArr[0] + "..."
+		}
+	} else if len(info) > 200 {
+		info = info[0:200] + "..."
+	}
+
+	return fmt.Sprintf("```\n%s\n```", info)
+}

+ 13 - 17
internal/integrations/slack/incidents_notifier.go → internal/notifier/slack/incident_notifier.go

@@ -5,27 +5,23 @@ import (
 	"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 {
+type IncidentNotifier struct {
 	slackInts []*integrations.SlackIntegration
-	Config    *types.NotificationConfig
 }
 
-func NewIncidentsNotifier(conf *types.NotificationConfig, slackInts ...*integrations.SlackIntegration) *IncidentsNotifier {
-	return &IncidentsNotifier{
+func NewIncidentNotifier(slackInts ...*integrations.SlackIntegration) *IncidentNotifier {
+	return &IncidentNotifier{
 		slackInts: slackInts,
-		Config:    conf,
 	}
 }
 
-func (s *IncidentsNotifier) NotifyNew(incident *porter_agent.Incident, url string) error {
+func (s *IncidentNotifier) NotifyNew(incident *types.Incident, url string) error {
 	res := []*SlackBlock{}
 
 	topSectionMarkdwn := fmt.Sprintf(
@@ -34,21 +30,20 @@ func (s *IncidentsNotifier) NotifyNew(incident *porter_agent.Incident, url strin
 		url,
 	)
 
-	namespace := strings.Split(incident.ID, ":")[2]
-	createdAt := time.Unix(incident.CreatedAt, 0).UTC()
+	createdAt := incident.CreatedAt
 
 	res = append(
 		res,
 		getMarkdownBlock(topSectionMarkdwn),
 		getDividerBlock(),
-		getMarkdownBlock(fmt.Sprintf("*Namespace:* %s", "`"+namespace+"`")),
+		getMarkdownBlock(fmt.Sprintf("*Namespace:* %s", "`"+incident.ReleaseNamespace+"`")),
 		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)),
+		getMarkdownBlock(fmt.Sprintf("```\n%s\n```", incident.Summary)),
 	)
 
 	slackPayload := &SlackPayload{
@@ -77,12 +72,11 @@ func (s *IncidentsNotifier) NotifyNew(incident *porter_agent.Incident, url strin
 	return nil
 }
 
-func (s *IncidentsNotifier) NotifyResolved(incident *porter_agent.Incident, url string) error {
+func (s *IncidentNotifier) NotifyResolved(incident *types.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()
+	createdAt := incident.CreatedAt
+	resolvedAt := incident.UpdatedAt
 
 	topSectionMarkdwn := fmt.Sprintf(
 		":white_check_mark: The incident for application %s has been resolved. <%s|View the incident.>",
@@ -94,7 +88,7 @@ func (s *IncidentsNotifier) NotifyResolved(incident *porter_agent.Incident, url
 		res,
 		getMarkdownBlock(topSectionMarkdwn),
 		getDividerBlock(),
-		getMarkdownBlock(fmt.Sprintf("*Namespace:* %s", "`"+namespace+"`")),
+		getMarkdownBlock(fmt.Sprintf("*Namespace:* %s", "`"+incident.ReleaseNamespace+"`")),
 		getMarkdownBlock(fmt.Sprintf("*Name:* %s", "`"+incident.ReleaseName+"`")),
 		getMarkdownBlock(fmt.Sprintf(
 			"*Created at:* <!date^%d^ {date_num} {time_secs}| %s>",
@@ -106,6 +100,8 @@ func (s *IncidentsNotifier) NotifyResolved(incident *porter_agent.Incident, url
 			resolvedAt.Unix(),
 			resolvedAt.Format("2006-01-02 15:04:05 UTC"),
 		)),
+		getMarkdownBlock(fmt.Sprintf("*Incident Summary:*")),
+		getMarkdownBlock(fmt.Sprintf("```\n%s\n```", incident.Summary)),
 	)
 
 	slackPayload := &SlackPayload{

+ 0 - 0
internal/notifier/notifier.go → internal/notifier/user_notifier.go


Einige Dateien werden nicht angezeigt, da zu viele Dateien in diesem Diff geändert wurden.