Răsfoiți Sursa

Merge branch 'master' into nafees/preview-env-token

Mohammed Nafees 3 ani în urmă
părinte
comite
fc436637f0
100 a modificat fișierele cu 6216 adăugiri și 17532 ștergeri
  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. 98 9
      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. 78 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. 1 1
      api/server/handlers/cluster/upgrade_agent.go
  17. 0 5
      api/server/handlers/infra/stream_logs.go
  18. 0 409
      api/server/handlers/kube_events/create.go
  19. 0 96
      api/server/handlers/kube_events/get_log_buckets.go
  20. 0 97
      api/server/handlers/kube_events/get_logs.go
  21. 1 1
      api/server/handlers/namespace/create_env_group.go
  22. 73 0
      api/server/handlers/namespace/stream_pod_logs_loki.go
  23. 6 1
      api/server/handlers/registry/list_images.go
  24. 1 1
      api/server/handlers/release/create.go
  25. 1 1
      api/server/handlers/release/create_addon.go
  26. 1 1
      api/server/handlers/release/update_image_batch.go
  27. 10 8
      api/server/handlers/release/upgrade.go
  28. 9 8
      api/server/handlers/release/upgrade_webhook.go
  29. 3 2
      api/server/handlers/stack/helpers.go
  30. 1 1
      api/server/handlers/v1/env_group/create.go
  31. 9 8
      api/server/handlers/v1/release/upgrade.go
  32. 164 82
      api/server/router/cluster.go
  33. 34 0
      api/server/router/namespace.go
  34. 12 6
      api/server/shared/config/env/envconfs.go
  35. 5 3
      api/server/shared/config/loader/loader.go
  36. 8 2
      api/types/agent.go
  37. 6 15
      api/types/cluster.go
  38. 193 0
      api/types/incident.go
  39. 3 1
      cli/cmd/apply.go
  40. 24 3
      cli/cmd/run.go
  41. 1 14275
      dashboard/package-lock.json
  42. 7 1
      dashboard/package.json
  43. 4 0
      dashboard/src/assets/danger.svg
  44. 6 0
      dashboard/src/assets/document.svg
  45. 4 0
      dashboard/src/assets/down-arrow.svg
  46. 3 0
      dashboard/src/assets/filter-outline.svg
  47. 5 0
      dashboard/src/assets/info-circle.svg
  48. 5 0
      dashboard/src/assets/info-outlined.svg
  49. 4 0
      dashboard/src/assets/time.svg
  50. 9 8
      dashboard/src/components/Banner.tsx
  51. 3 1
      dashboard/src/components/Boilerplate.tsx
  52. 10 0
      dashboard/src/components/Loading.tsx
  53. 9 45
      dashboard/src/components/RadioFilter.tsx
  54. 70 0
      dashboard/src/components/date-time-picker/DateTimePicker.tsx
  55. 771 0
      dashboard/src/components/date-time-picker/react-datepicker.css
  56. 1 1
      dashboard/src/components/porter-form/PorterForm.tsx
  57. 16 2
      dashboard/src/components/porter-form/PorterFormWrapper.tsx
  58. 2 80
      dashboard/src/main/home/cluster-dashboard/chart/JobRunTable.tsx
  59. 75 16
      dashboard/src/main/home/cluster-dashboard/dashboard/ClusterSettings.tsx
  60. 0 3
      dashboard/src/main/home/cluster-dashboard/dashboard/Dashboard.tsx
  61. 0 4
      dashboard/src/main/home/cluster-dashboard/dashboard/Routes.tsx
  62. 1 3
      dashboard/src/main/home/cluster-dashboard/dashboard/events/EventsTab.tsx
  63. 0 233
      dashboard/src/main/home/cluster-dashboard/dashboard/incidents/EventDrawer.tsx
  64. 0 62
      dashboard/src/main/home/cluster-dashboard/dashboard/incidents/ExpandedContainer.tsx
  65. 0 524
      dashboard/src/main/home/cluster-dashboard/dashboard/incidents/IncidentPage.tsx
  66. 0 214
      dashboard/src/main/home/cluster-dashboard/dashboard/incidents/IncidentsTab.tsx
  67. 0 209
      dashboard/src/main/home/cluster-dashboard/dashboard/incidents/IncidentsTable.tsx
  68. 263 144
      dashboard/src/main/home/cluster-dashboard/expanded-chart/ExpandedChart.tsx
  69. 1 1
      dashboard/src/main/home/cluster-dashboard/expanded-chart/ExpandedChartWrapper.tsx
  70. 3 2
      dashboard/src/main/home/cluster-dashboard/expanded-chart/ExpandedJobChart.tsx
  71. 5 7
      dashboard/src/main/home/cluster-dashboard/expanded-chart/RevisionSection.tsx
  72. 393 0
      dashboard/src/main/home/cluster-dashboard/expanded-chart/deploy-status-section/ControllerTab.tsx
  73. 219 0
      dashboard/src/main/home/cluster-dashboard/expanded-chart/deploy-status-section/DeployStatusSection.tsx
  74. 149 0
      dashboard/src/main/home/cluster-dashboard/expanded-chart/deploy-status-section/PodDropdown.tsx
  75. 223 0
      dashboard/src/main/home/cluster-dashboard/expanded-chart/deploy-status-section/PodRow.tsx
  76. 316 0
      dashboard/src/main/home/cluster-dashboard/expanded-chart/deploy-status-section/ResourceTab.tsx
  77. 53 0
      dashboard/src/main/home/cluster-dashboard/expanded-chart/deploy-status-section/util.ts
  78. 487 0
      dashboard/src/main/home/cluster-dashboard/expanded-chart/events/EventList.tsx
  79. 99 0
      dashboard/src/main/home/cluster-dashboard/expanded-chart/events/EventTable.tsx
  80. 68 149
      dashboard/src/main/home/cluster-dashboard/expanded-chart/events/EventsTab.tsx
  81. 155 0
      dashboard/src/main/home/cluster-dashboard/expanded-chart/events/styles.ts
  82. 0 216
      dashboard/src/main/home/cluster-dashboard/expanded-chart/incidents/IncidentsTab.tsx
  83. 0 217
      dashboard/src/main/home/cluster-dashboard/expanded-chart/incidents/IncidentsTable.tsx
  84. 96 61
      dashboard/src/main/home/cluster-dashboard/expanded-chart/jobs/ExpandedJobRun.tsx
  85. 4 4
      dashboard/src/main/home/cluster-dashboard/expanded-chart/jobs/JobResource.tsx
  86. 685 0
      dashboard/src/main/home/cluster-dashboard/expanded-chart/logs-section/LogsSection.tsx
  87. 401 0
      dashboard/src/main/home/cluster-dashboard/expanded-chart/logs-section/useAgentLogs.ts
  88. 14 74
      dashboard/src/main/home/cluster-dashboard/expanded-chart/metrics/JobMetricsSection.tsx
  89. 1 1
      dashboard/src/main/home/cluster-dashboard/expanded-chart/metrics/MetricsSection.tsx
  90. 3 4
      dashboard/src/main/home/cluster-dashboard/expanded-chart/status/Logs.tsx
  91. 1 7
      dashboard/src/main/home/cluster-dashboard/expanded-chart/status/StatusSection.tsx
  92. 16 16
      dashboard/src/main/home/dashboard/ClusterList.tsx
  93. 1 1
      dashboard/src/main/home/launch/Launch.tsx
  94. 127 63
      dashboard/src/shared/api.tsx
  95. 82 0
      dashboard/src/shared/string_utils.ts
  96. 1 0
      dashboard/src/shared/types.tsx
  97. 8 0
      dashboard/src/shared/util.ts
  98. 14 1
      dashboard/webpack.config.js
  99. 8 2
      internal/helm/agent.go
  100. 2 1
      internal/helm/postrenderer.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)
+}

+ 98 - 9
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,42 @@ func (c *InstallAgentHandler) ServeHTTP(w http.ResponseWriter, r *http.Request)
 		return
 	}
 
+	nodes, err := nodes.ListNodesByLabels(k8sAgent.Clientset, "porter.run/workload-kind=monitoring")
+	hasMonitoringNodes := err == nil && len(nodes) >= 1
+
 	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": 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 hasMonitoringNodes {
+		sharedNS := map[string]interface{}{
+			"porter.run/workload-kind": "monitoring",
+		}
+
+		sharedTolerations := []map[string]interface{}{
+			{
+				"key":      "porter.run/workload-kind",
+				"operator": "Equal",
+				"value":    "monitoring",
+				"effect":   "NoSchedule",
+			},
+		}
+
+		porterAgentValues["loki"] = map[string]interface{}{
+			"nodeSelector": sharedNS,
+			"tolerations":  sharedTolerations,
+		}
+
+		porterAgentValues["nodeSelector"] = sharedNS
+		porterAgentValues["tolerations"] = sharedTolerations
 	}
 
 	conf := &helm.InstallChartConfig{
@@ -96,12 +144,11 @@ func (c *InstallAgentHandler) ServeHTTP(w http.ResponseWriter, r *http.Request)
 		Values:    porterAgentValues,
 	}
 
-	_, err = helmAgent.InstallChart(conf, c.Config().DOConf)
+	_, err = helmAgent.InstallChart(conf, c.Config().DOConf, c.Config().ServerConf.DisablePullSecretsInjection)
 
 	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 +156,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))

+ 78 - 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,81 @@ 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,
+			)
+		}
+
+		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)
 

+ 1 - 1
api/server/handlers/cluster/upgrade_agent.go

@@ -66,7 +66,7 @@ func (c *UpgradeAgentHandler) ServeHTTP(w http.ResponseWriter, r *http.Request)
 		Cluster:    cluster,
 		Repo:       c.Repo(),
 		Registries: []*models.Registry{},
-	}, c.Config().DOConf)
+	}, c.Config().DOConf, c.Config().ServerConf.DisablePullSecretsInjection)
 
 	if err != nil {
 		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(

+ 0 - 5
api/server/handlers/infra/stream_logs.go

@@ -3,7 +3,6 @@ package infra
 import (
 	"context"
 	"errors"
-	"fmt"
 	"io"
 	"net/http"
 	"sync"
@@ -68,7 +67,6 @@ func (c *InfraStreamLogHandler) ServeHTTP(w http.ResponseWriter, r *http.Request
 		for {
 			if _, _, err := safeRW.ReadMessage(); err != nil {
 				errorchan <- nil
-				fmt.Println("closing websocket goroutine")
 				return
 			}
 		}
@@ -87,8 +85,6 @@ func (c *InfraStreamLogHandler) ServeHTTP(w http.ResponseWriter, r *http.Request
 					errorchan <- err
 				}
 
-				fmt.Println("closing grpc goroutine")
-
 				return
 			}
 
@@ -96,7 +92,6 @@ func (c *InfraStreamLogHandler) ServeHTTP(w http.ResponseWriter, r *http.Request
 
 			if err != nil {
 				errorchan <- nil
-				fmt.Println("closing grpc goroutine")
 				return
 			}
 		}

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

+ 1 - 1
api/server/handlers/namespace/create_env_group.go

@@ -191,7 +191,7 @@ func rolloutApplications(
 				Values:     newConfig,
 			}
 
-			_, err = helmAgent.UpgradeReleaseByValues(conf, config.DOConf)
+			_, err = helmAgent.UpgradeReleaseByValues(conf, config.DOConf, config.ServerConf.DisablePullSecretsInjection)
 
 			if err != nil {
 				mu.Lock()

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

+ 6 - 1
api/server/handlers/registry/list_images.go

@@ -1,7 +1,9 @@
 package registry
 
 import (
+	"fmt"
 	"net/http"
+	"strings"
 
 	"github.com/porter-dev/porter/api/server/handlers"
 	"github.com/porter-dev/porter/api/server/shared"
@@ -37,7 +39,10 @@ func (c *RegistryListImagesHandler) ServeHTTP(w http.ResponseWriter, r *http.Req
 
 	imgs, err := regAPI.ListImages(repoName, c.Repo(), c.Config().DOConf)
 
-	if err != nil {
+	if err != nil && strings.Contains(err.Error(), "RepositoryNotFoundException") {
+		c.HandleAPIError(w, r, apierrors.NewErrNotFound(fmt.Errorf("no such repository: %s", repoName)))
+		return
+	} else if err != nil {
 		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
 		return
 	}

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

@@ -104,7 +104,7 @@ func (c *CreateReleaseHandler) ServeHTTP(w http.ResponseWriter, r *http.Request)
 		Registries: registries,
 	}
 
-	helmRelease, err := helmAgent.InstallChart(conf, c.Config().DOConf)
+	helmRelease, err := helmAgent.InstallChart(conf, c.Config().DOConf, c.Config().ServerConf.DisablePullSecretsInjection)
 
 	if err != nil {
 		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(

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

@@ -94,7 +94,7 @@ func (c *CreateAddonHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
 		Registries: registries,
 	}
 
-	helmRelease, err := helmAgent.InstallChart(conf, c.Config().DOConf)
+	helmRelease, err := helmAgent.InstallChart(conf, c.Config().DOConf, c.Config().ServerConf.DisablePullSecretsInjection)
 
 	if err != nil {
 		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(

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

@@ -108,7 +108,7 @@ func (c *UpdateImageBatchHandler) ServeHTTP(w http.ResponseWriter, r *http.Reque
 					Values:     rel.Config,
 				}
 
-				_, err = helmAgent.UpgradeReleaseByValues(conf, c.Config().DOConf)
+				_, err = helmAgent.UpgradeReleaseByValues(conf, c.Config().DOConf, c.Config().ServerConf.DisablePullSecretsInjection)
 
 				if err != nil {
 					// if this is a release not found error, just return - the release has likely been deleted from the underlying

+ 10 - 8
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"
 )
@@ -159,7 +160,8 @@ func (c *UpgradeReleaseHandler) ServeHTTP(w http.ResponseWriter, r *http.Request
 		}
 	}
 
-	newHelmRelease, upgradeErr := helmAgent.UpgradeRelease(conf, request.Values, c.Config().DOConf)
+	newHelmRelease, upgradeErr := helmAgent.UpgradeRelease(conf, request.Values, c.Config().DOConf,
+		c.Config().ServerConf.DisablePullSecretsInjection)
 
 	if upgradeErr == nil && newHelmRelease != nil {
 		helmRelease = newHelmRelease
@@ -182,9 +184,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 +203,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 +219,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)
 		}
 	}
 

+ 9 - 8
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,
@@ -173,14 +174,14 @@ func (c *WebhookHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
 		),
 	}
 
-	rel, err = helmAgent.UpgradeReleaseByValues(conf, c.Config().DOConf)
+	rel, err = helmAgent.UpgradeReleaseByValues(conf, c.Config().DOConf, c.Config().ServerConf.DisablePullSecretsInjection)
 
 	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)
 		}
 	}
 

+ 3 - 2
api/server/handlers/stack/helpers.go

@@ -54,7 +54,7 @@ func applyAppResource(opts *applyAppResourceOpts) (*release.Release, error) {
 		"revision": opts.stackRevision,
 	}
 
-	return opts.helmAgent.InstallChart(conf, opts.config.DOConf)
+	return opts.helmAgent.InstallChart(conf, opts.config.DOConf, opts.config.ServerConf.DisablePullSecretsInjection)
 }
 
 type rollbackAppResourceOpts struct {
@@ -106,7 +106,8 @@ func updateAppResourceTag(opts *updateAppResourceTagOpts) error {
 		StackRevision: opts.stackRevision,
 	}
 
-	_, err = opts.helmAgent.UpgradeReleaseByValues(conf, opts.config.DOConf)
+	_, err = opts.helmAgent.UpgradeReleaseByValues(conf, opts.config.DOConf,
+		opts.config.ServerConf.DisablePullSecretsInjection)
 
 	return err
 }

+ 1 - 1
api/server/handlers/v1/env_group/create.go

@@ -207,7 +207,7 @@ func rolloutApplications(
 				Values:     newConfig,
 			}
 
-			_, err = helmAgent.UpgradeReleaseByValues(conf, config.DOConf)
+			_, err = helmAgent.UpgradeReleaseByValues(conf, config.DOConf, config.ServerConf.DisablePullSecretsInjection)
 
 			if err != nil {
 				mu.Lock()

+ 9 - 8
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"
 )
 
@@ -143,7 +144,7 @@ func (c *UpgradeReleaseHandler) ServeHTTP(w http.ResponseWriter, r *http.Request
 		}
 	}
 
-	newHelmRelease, upgradeErr := helmAgent.UpgradeReleaseByValues(conf, c.Config().DOConf)
+	newHelmRelease, upgradeErr := helmAgent.UpgradeReleaseByValues(conf, c.Config().DOConf, c.Config().ServerConf.DisablePullSecretsInjection)
 
 	if upgradeErr == nil && newHelmRelease != nil {
 		helmRelease = newHelmRelease
@@ -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"
@@ -801,6 +800,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{
@@ -830,14 +854,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,
@@ -847,55 +871,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,
@@ -905,26 +992,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,
@@ -934,26 +1021,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,
@@ -963,26 +1050,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,
@@ -992,25 +1079,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,
@@ -1020,90 +1108,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,
@@ -1113,26 +1195,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,
@@ -1142,26 +1224,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,
@@ -1171,15 +1253,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{

+ 12 - 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"`
@@ -104,6 +106,10 @@ type ServerConf struct {
 
 	// Enable gitlab integration
 	EnableGitlab bool `env:"ENABLE_GITLAB,default=false"`
+
+	// DisableRegistrySecretsInjection is used to denote if Porter should not inject
+	// imagePullSecrets into a kubernetes deployment (Porter application)
+	DisablePullSecretsInjection bool `env:"DISABLE_PULL_SECRETS_INJECTION,default=false"`
 }
 
 // DBConf is the database configuration: if generated from environment variables,

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

+ 193 - 0
api/types/incident.go

@@ -0,0 +1,193 @@
+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"`
+	Namespace   string     `schema:"namespace"`
+	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"`
+}

+ 3 - 1
cli/cmd/apply.go

@@ -747,7 +747,9 @@ func (t *DeploymentHook) PreApply() error {
 	envs := *envList
 
 	for _, env := range envs {
-		if env.GitRepoOwner == t.repoOwner && env.GitRepoName == t.repoName && env.GitInstallationID == t.gitInstallationID {
+		if strings.EqualFold(env.GitRepoOwner, t.repoOwner) &&
+			strings.EqualFold(env.GitRepoName, t.repoName) &&
+			env.GitInstallationID == t.gitInstallationID {
 			t.envID = env.ID
 			break
 		}

+ 24 - 3
cli/cmd/run.go

@@ -111,12 +111,33 @@ func init() {
 }
 
 func run(_ *types.GetAuthenticatedUserResponse, client *api.Client, args []string) error {
-	color.New(color.FgGreen).Println("Running", strings.Join(args[1:], " "), "for release", args[0])
+	execArgs := args[1:]
+
+	color.New(color.FgGreen).Println("Running", strings.Join(execArgs, " "), "for release", args[0])
 
 	if nonInteractive {
 		color.New(color.FgBlue).Println("Using non-interactive mode. The first available pod will be used to run the command.")
 	}
 
+	if len(execArgs) > 0 {
+		release, err := client.GetRelease(
+			context.Background(), cliConf.Project, cliConf.Cluster, namespace, args[0],
+		)
+
+		if err != nil {
+			return fmt.Errorf("error fetching release %s: %w", args[0], err)
+		}
+
+		if release.BuildConfig != nil &&
+			(strings.Contains(release.BuildConfig.Builder, "heroku") ||
+				strings.Contains(release.BuildConfig.Builder, "paketo")) &&
+			execArgs[0] != "/cnb/lifecycle/launcher" &&
+			execArgs[0] != "launcher" {
+			// this is a buildpacks release using a heroku builder, prepend the launcher
+			execArgs = append([]string{"/cnb/lifecycle/launcher"}, execArgs...)
+		}
+	}
+
 	podsSimple, err := getPods(client, namespace, args[0])
 
 	if err != nil {
@@ -202,10 +223,10 @@ func run(_ *types.GetAuthenticatedUserResponse, client *api.Client, args []strin
 	}
 
 	if existingPod {
-		return executeRun(config, namespace, selectedPod.Name, selectedContainerName, args[1:])
+		return executeRun(config, namespace, selectedPod.Name, selectedContainerName, execArgs)
 	}
 
-	return executeRunEphemeral(config, namespace, selectedPod.Name, selectedContainerName, args[1:])
+	return executeRunEphemeral(config, namespace, selectedPod.Name, selectedContainerName, execArgs)
 }
 
 func cleanup(_ *types.GetAuthenticatedUserResponse, client *api.Client, _ []string) error {

Fișier diff suprimat deoarece este prea mare
+ 1 - 14275
dashboard/package-lock.json


+ 7 - 1
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",
     "highlight.run": "^1.4.5",
     "ini": ">=1.3.6",
@@ -45,8 +46,10 @@
     "react": "^16.13.1",
     "react-ace": "^9.1.3",
     "react-color": "^2.19.3",
+    "react-datepicker": "^4.8.0",
     "react-dom": "^16.13.1",
     "react-error-boundary": "^3.1.3",
+    "react-hot-toast": "^2.4.0",
     "react-infinite-scroll-component": "^6.1.0",
     "react-modal": "^3.11.2",
     "react-router-dom": "^5.2.0",
@@ -60,7 +63,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"
   },
@@ -89,6 +92,7 @@
     "@types/random-words": "^1.1.0",
     "@types/react": "^16.14.14",
     "@types/react-color": "^3.0.6",
+    "@types/react-datepicker": "^4.4.2",
     "@types/react-dom": "^16.9.8",
     "@types/react-modal": "^3.10.6",
     "@types/react-router": "^5.1.8",
@@ -101,12 +105,14 @@
     "babel-loader": "^8.2.2",
     "babel-plugin-lodash": "^3.3.4",
     "babel-plugin-styled-components": "^1.13.3",
+    "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",

+ 4 - 0
dashboard/src/assets/danger.svg

@@ -0,0 +1,4 @@
+<svg width="88" height="88" viewBox="0 0 88 88" fill="none" xmlns="http://www.w3.org/2000/svg">
+<path opacity="0.4" d="M17.3164 77.6135C17.2687 77.6135 17.2247 77.6135 17.1734 77.6098C16.0184 77.5511 14.8854 77.3018 13.8074 76.8655C8.50171 74.7095 5.94237 68.6485 8.09471 63.3465L34.9384 16.3178C35.8624 14.6458 37.263 13.2451 38.9717 12.2991C43.9767 9.52715 50.3054 11.3495 53.0737 16.3508L79.7414 63.0201C80.3354 64.4171 80.5884 65.5538 80.6507 66.7125C80.7937 69.4845 79.8477 72.1428 77.9924 74.1998C76.137 76.2568 73.5887 77.4705 70.8204 77.6098L17.5804 77.6135H17.3164Z" fill="white"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M40.79 36.743C40.79 34.9757 42.231 33.5347 43.9984 33.5347C45.7657 33.5347 47.2067 34.9757 47.2067 36.743V47.1123C47.2067 48.8833 45.7657 50.3207 43.9984 50.3207C42.231 50.3207 40.79 48.8833 40.79 47.1123V36.743ZM40.79 59.6564C40.79 57.878 42.231 56.4297 43.9984 56.4297C45.7657 56.4297 47.2067 57.8597 47.2067 59.616C47.2067 61.4237 45.7657 62.8647 43.9984 62.8647C42.231 62.8647 40.79 61.4237 40.79 59.6564Z" fill="white"/>
+</svg>

+ 6 - 0
dashboard/src/assets/document.svg

@@ -0,0 +1,6 @@
+<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
+<path d="M15.7162 16.2234H8.49622" stroke="white" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M15.7162 12.0369H8.49622" stroke="white" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M11.2513 7.86011H8.49631" stroke="white" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M15.9086 2.74982C15.9086 2.74982 8.23161 2.75382 8.21961 2.75382C5.45961 2.77082 3.75061 4.58682 3.75061 7.35682V16.5528C3.75061 19.3368 5.47261 21.1598 8.25661 21.1598C8.25661 21.1598 15.9326 21.1568 15.9456 21.1568C18.7056 21.1398 20.4156 19.3228 20.4156 16.5528V7.35682C20.4156 4.57282 18.6926 2.74982 15.9086 2.74982Z" stroke="white" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+</svg>

+ 4 - 0
dashboard/src/assets/down-arrow.svg

@@ -0,0 +1,4 @@
+<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
+<path d="M12.2743 19.75V4.75" stroke="white" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M18.2987 13.7002L12.2747 19.7502L6.24969 13.7002" stroke="white" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+</svg>

+ 3 - 0
dashboard/src/assets/filter-outline.svg

@@ -0,0 +1,3 @@
+<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
+<path fill-rule="evenodd" clip-rule="evenodd" d="M9.29332 22L14.0696 19.7519V13.8603L21.5593 6.26456C21.8416 5.97995 22 5.58933 22 5.18027V3.51754C22 2.67869 21.3417 2 20.5295 2H3.47049C2.65826 2 2 2.67869 2 3.51754V5.2183C2 5.60431 2.14169 5.97534 2.39719 6.2565L9.29332 13.8603V22Z" stroke="white" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+</svg>

+ 5 - 0
dashboard/src/assets/info-circle.svg

@@ -0,0 +1,5 @@
+<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
+<path fill-rule="evenodd" clip-rule="evenodd" d="M9.99988 0.750183C15.1089 0.750183 19.2499 4.89218 19.2499 10.0002C19.2499 15.1082 15.1089 19.2502 9.99988 19.2502C4.89188 19.2502 0.749878 15.1082 0.749878 10.0002C0.749878 4.89218 4.89188 0.750183 9.99988 0.750183Z" stroke="white" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M9.995 6.20428V10.6233" stroke="white" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M9.995 13.7961H10.005" stroke="white" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+</svg>

+ 5 - 0
dashboard/src/assets/info-outlined.svg

@@ -0,0 +1,5 @@
+<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
+<path d="M11.9899 15.7961V11.3771" stroke="white" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M11.9899 8.20428H11.9999" stroke="white" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M16.3346 2.75018H7.66561C4.64461 2.75018 2.75061 4.88918 2.75061 7.91618V16.0842C2.75061 19.1112 4.63561 21.2502 7.66561 21.2502H16.3336C19.3646 21.2502 21.2506 19.1112 21.2506 16.0842V7.91618C21.2506 4.88918 19.3646 2.75018 16.3346 2.75018Z" stroke="white" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+</svg>

+ 4 - 0
dashboard/src/assets/time.svg

@@ -0,0 +1,4 @@
+<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
+<path fill-rule="evenodd" clip-rule="evenodd" d="M21.2498 12.0005C21.2498 17.1095 17.1088 21.2505 11.9998 21.2505C6.8908 21.2505 2.7498 17.1095 2.7498 12.0005C2.7498 6.89149 6.8908 2.75049 11.9998 2.75049C17.1088 2.75049 21.2498 6.89149 21.2498 12.0005Z" stroke="white" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M15.4314 14.9429L11.6614 12.6939V7.84686" stroke="white" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+</svg>

+ 9 - 8
dashboard/src/components/Banner.tsx

@@ -6,16 +6,17 @@ import warning from "assets/warning.png";
 
 interface Props {
   type?: string;
+  icon?: React.ReactNode;
   children: React.ReactNode;
   noMargin?: boolean;
 }
 
-const Banner: React.FC<Props> = ({ 
-  type, 
-  children,
-  noMargin,
-}) => {
+const Banner: React.FC<Props> = ({ type, icon, children, noMargin }) => {
   const renderIcon = () => {
+    if (icon) {
+      return icon;
+    }
+
     if (type === "error" || type === "warning") {
       return <i className="material-icons-round">warning</i>;
     }
@@ -35,19 +36,19 @@ const Banner: React.FC<Props> = ({
 
 export default Banner;
 
-const StyledBanner = styled.div<{ 
+const StyledBanner = styled.div<{
   color?: string;
   noMargin?: boolean;
 }>`
   height: 40px;
   width: 100%;
-  margin: ${props => props.noMargin ? "5px 0 10px" : ""};
+  margin: ${(props) => (props.noMargin ? "5px 0 10px" : "")};
   font-size: 13px;
   font-family: "Work Sans", sans-serif;
   display: flex;
   border: 1px solid ${(props) => props.color || "#ffffff00"};
   border-radius: 8px;
-  padding-left: 14px;
+  padding: 14px;
   color: ${(props) => props.color || "#ffffff"};
   align-items: center;
   background: #ffffff11;

+ 3 - 1
dashboard/src/components/Boilerplate.tsx

@@ -4,10 +4,12 @@ import styled from "styled-components";
 
 type Props = {};
 
-export const Boilerplate: React.FC<Props> = (props) => {
+const Boilerplate: React.FC<Props> = (props) => {
   const [someState, setSomeState] = useState("");
 
   return <StyledBoilerplate></StyledBoilerplate>;
 };
 
+export default Boilerplate;
+
 const StyledBoilerplate = styled.div``;

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

+ 9 - 45
dashboard/src/components/RadioFilter.tsx

@@ -90,7 +90,7 @@ const RadioFilter: React.FC<Props> = (props) => {
         noMargin={props.noMargin}
       >
         {props.icon && <FilterIcon src={props.icon} />}
-        {props.name}
+        <TextAlt>{props.name}</TextAlt>
         <Bar />
         <Selected>
           {props.selected
@@ -113,6 +113,7 @@ const Bar = styled.div`
   height: calc(18px);
   background: #494b4f;
   margin: 0 8px;
+  margin-left: 0;
 `;
 
 const Selected = styled.div`
@@ -131,6 +132,13 @@ const Text = styled.div`
   margin-right: 10px;
 `;
 
+const TextAlt = styled(Text)`
+  overflow: hidden;
+  white-space: nowrap;
+  text-overflow: ellipsis;
+  word-break: anywhere;
+`;
+
 const OptionRow = styled.div<{ isLast: boolean; selected?: boolean }>`
   width: 100%;
   height: 35px;
@@ -146,21 +154,6 @@ const OptionRow = styled.div<{ isLast: boolean; selected?: boolean }>`
   }
 `;
 
-const FilterCount = styled.div`
-  padding: 5px;
-  color: #ffffff;
-  background: #ffffff11;
-  margin-left: 7px;
-  font-size: 12px;
-  border-radius: 50px;
-  margin-right: -5px;
-  height: 20px;
-  display: flex;
-  align-items: center;
-  justify-content: center;
-  min-width: 20px;
-`;
-
 const Placeholder = styled.div`
   color: #aaaabb88;
   font-size: 12px;
@@ -176,35 +169,6 @@ const ScrollableWrapper = styled.div`
   max-height: 350px;
 `;
 
-const Label = styled.div`
-  height: 37px;
-  display: flex;
-  align-items: center;
-  margin-left: 10px;
-  font-size: 13px;
-`;
-
-const Option: any = styled.div`
-  width: 100%;
-  border-top: 1px solid #00000000;
-  height: 37px;
-  font-size: 13px;
-  align-items: center;
-  display: flex;
-  align-items: center;
-  padding-left: 15px;
-  cursor: pointer;
-  padding-right: 10px;
-  white-space: nowrap;
-  overflow: hidden;
-  text-overflow: ellipsis;
-  background: ${(props: any) => (props.selected ? "#ffffff11" : "")};
-
-  :hover {
-    background: #ffffff22;
-  }
-`;
-
 const Relative = styled.div`
   position: relative;
 `;

+ 70 - 0
dashboard/src/components/date-time-picker/DateTimePicker.tsx

@@ -0,0 +1,70 @@
+import React, { useState } from "react";
+
+import DatePicker from "react-datepicker";
+import time from "assets/time.svg";
+
+import styled from "styled-components";
+import "./react-datepicker.css";
+
+type Props = {
+  startDate: any;
+  setStartDate: any;
+};
+
+const DateTimePicker: React.FC<Props> = ({ startDate, setStartDate }) => {
+  return (
+    <DateTimePickerWrapper
+      onClick={(e) => {
+        e.stopPropagation();
+        e.nativeEvent.stopImmediatePropagation();
+      }}
+    >
+      <Bar />
+      <StyledDatePicker
+        selected={startDate}
+        onChange={(date: any) => setStartDate(date)}
+        showTimeSelect
+        dateFormat="MMMM d, yyyy h:mm aa"
+      />
+    </DateTimePickerWrapper>
+  );
+};
+
+export default DateTimePicker;
+
+const Bar = styled.div`
+  width: 1px;
+  height: calc(18px);
+  background: #494b4f;
+  margin-left: 8px;
+`;
+
+const TimeIcon = styled.img`
+  width: 16px;
+  height: 16px;
+  z-index: 999;
+`;
+
+const Div = styled.div`
+  display: block;
+`;
+
+const DateTimePickerWrapper = styled.div`
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  padding-right: 42px;
+  margin-left: 2px;
+`;
+
+const StyledDatePicker = styled(DatePicker)`
+  border: 0;
+  width: calc(100% + 42px);
+  position: relative;
+  border: none;
+  outline-width: 0;
+  background: transparent;
+  text-align: center;
+  padding: 0 15px;
+  font-size: 13px;
+`;

+ 771 - 0
dashboard/src/components/date-time-picker/react-datepicker.css

@@ -0,0 +1,771 @@
+.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,
+.react-datepicker__month-read-view--down-arrow,
+.react-datepicker__month-year-read-view--down-arrow {
+  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,
+.react-datepicker__month-read-view--down-arrow,
+.react-datepicker__month-year-read-view--down-arrow,
+.react-datepicker-popper[data-placement^="bottom"]
+  .react-datepicker__triangle::before,
+.react-datepicker-popper[data-placement^="top"]
+  .react-datepicker__triangle::before,
+.react-datepicker__year-read-view--down-arrow::before,
+.react-datepicker__month-read-view--down-arrow::before,
+.react-datepicker__month-year-read-view--down-arrow::before {
+  box-sizing: content-box;
+  position: absolute;
+  border: 8px solid transparent;
+  height: 0;
+  width: 1px;
+}
+.react-datepicker-popper[data-placement^="bottom"]
+  .react-datepicker__triangle::before,
+.react-datepicker-popper[data-placement^="top"]
+  .react-datepicker__triangle::before,
+.react-datepicker__year-read-view--down-arrow::before,
+.react-datepicker__month-read-view--down-arrow::before,
+.react-datepicker__month-year-read-view--down-arrow::before {
+  content: "";
+  z-index: -1;
+  border-width: 8px;
+  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: #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,
+.react-datepicker__month-year-read-view--down-arrow {
+  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,
+.react-datepicker__month-year-read-view--down-arrow,
+.react-datepicker-popper[data-placement^="top"]
+  .react-datepicker__triangle::before,
+.react-datepicker__year-read-view--down-arrow::before,
+.react-datepicker__month-read-view--down-arrow::before,
+.react-datepicker__month-year-read-view--down-arrow::before {
+  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,
+.react-datepicker__month-read-view--down-arrow::before,
+.react-datepicker__month-year-read-view--down-arrow::before {
+  bottom: -1px;
+  border-top-color: #aeaeae;
+}
+.react-datepicker-wrapper {
+  display: inline-block;
+  padding: 0;
+  border: 0;
+}
+.react-datepicker {
+  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: #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,
+.react-datepicker__year-dropdown-container--scroll,
+.react-datepicker__month-dropdown-container--scroll,
+.react-datepicker__month-year-dropdown-container--scroll {
+  display: inline-block;
+  margin: 0 2px;
+}
+.react-datepicker__current-month,
+.react-datepicker-time__header,
+.react-datepicker-year-header {
+  margin-top: 0;
+  color: white;
+  font-weight: bold;
+  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;
+  text-align: center;
+  cursor: pointer;
+  position: absolute;
+  top: 10px;
+  width: 0;
+  padding: 0;
+  border: 0.45rem solid transparent;
+  z-index: 1;
+  height: 10px;
+  width: 10px;
+  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: 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;
+  display: block;
+  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%;
+  float: left;
+  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
+  input[type="time"]::-webkit-inner-spin-button,
+.react-datepicker__input-time-container
+  .react-datepicker-time__input-container
+  .react-datepicker-time__input
+  input[type="time"]::-webkit-outer-spin-button {
+  -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;
+  border-radius: 0.3rem;
+  position: absolute;
+  right: -72px;
+  top: 0;
+}
+.react-datepicker__time-container .react-datepicker__time {
+  position: relative;
+  background: #26292e;
+}
+.react-datepicker__time-container
+  .react-datepicker__time
+  .react-datepicker__time-box {
+  width: 85px;
+  overflow-x: hidden;
+  margin: 0 auto;
+  text-align: center;
+}
+.react-datepicker__time-container
+  .react-datepicker__time
+  .react-datepicker__time-box
+  ul.react-datepicker__time-list {
+  list-style: none;
+  margin: 0;
+  height: calc(195px + (1.7rem / 2));
+  overflow-y: scroll;
+  padding-right: 0px;
+  padding-left: 0px;
+  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: 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: #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: #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: #949eff;
+}
+.react-datepicker__time-container
+  .react-datepicker__time
+  .react-datepicker__time-box
+  ul.react-datepicker__time-list
+  li.react-datepicker__time-list-item--disabled {
+  color: #ccc;
+}
+.react-datepicker__time-container
+  .react-datepicker__time
+  .react-datepicker__time-box
+  ul.react-datepicker__time-list
+  li.react-datepicker__time-list-item--disabled:hover {
+  cursor: default;
+  background-color: transparent;
+}
+.react-datepicker__week-number {
+  color: #ccc;
+  display: inline-block;
+  width: 1.7rem;
+  line-height: 1.7rem;
+  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: #26292e;
+}
+.react-datepicker__day-names,
+.react-datepicker__week {
+  white-space: nowrap;
+}
+.react-datepicker__day-name,
+.react-datepicker__day,
+.react-datepicker__time-name {
+  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,
+.react-datepicker__quarter--selected,
+.react-datepicker__quarter--in-selecting-range,
+.react-datepicker__quarter--in-range {
+  border-radius: 0.3rem;
+  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: #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: #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 {
+  border-radius: 0.3rem;
+  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,
+.react-datepicker__month-text--selected,
+.react-datepicker__month-text--in-selecting-range,
+.react-datepicker__month-text--in-range,
+.react-datepicker__quarter-text--selected,
+.react-datepicker__quarter-text--in-selecting-range,
+.react-datepicker__quarter-text--in-range {
+  border-radius: 0.3rem;
+  background-color: #949eff;
+  color: #fff;
+}
+.react-datepicker__day--selected:hover,
+.react-datepicker__day--in-selecting-range:hover,
+.react-datepicker__day--in-range:hover,
+.react-datepicker__month-text--selected:hover,
+.react-datepicker__month-text--in-selecting-range:hover,
+.react-datepicker__month-text--in-range:hover,
+.react-datepicker__quarter-text--selected:hover,
+.react-datepicker__quarter-text--in-selecting-range:hover,
+.react-datepicker__quarter-text--in-range:hover {
+  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: #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: #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: #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,
+.react-datepicker__month-text.react-datepicker__quarter--in-range:hover,
+.react-datepicker__quarter-text.react-datepicker__month--selected:hover,
+.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: #949eff;
+}
+.react-datepicker__month-text:hover,
+.react-datepicker__quarter-text:hover {
+  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
+  .react-datepicker__month-read-view--down-arrow,
+.react-datepicker__month-read-view:hover
+  .react-datepicker__year-read-view--down-arrow,
+.react-datepicker__month-read-view:hover
+  .react-datepicker__month-read-view--down-arrow,
+.react-datepicker__month-year-read-view:hover
+  .react-datepicker__year-read-view--down-arrow,
+.react-datepicker__month-year-read-view:hover
+  .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 {
+  border-top-color: #ccc;
+  float: right;
+  margin-left: 20px;
+  top: 8px;
+  position: relative;
+  border-width: 0.45rem;
+}
+.react-datepicker__year-dropdown,
+.react-datepicker__month-dropdown,
+.react-datepicker__month-year-dropdown {
+  background-color: #26292e;
+  position: absolute;
+  width: 50%;
+  left: 25%;
+  top: 30px;
+  z-index: 1;
+  text-align: center;
+  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 {
+  line-height: 20px;
+  width: 100%;
+  display: block;
+  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 {
+  -webkit-user-select: none;
+  -moz-user-select: none;
+  -ms-user-select: none;
+  user-select: none;
+  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
+  .react-datepicker__navigation--years-upcoming,
+.react-datepicker__month-year-option:hover
+  .react-datepicker__navigation--years-upcoming {
+  border-bottom-color: #b3b3b3;
+}
+.react-datepicker__year-option:hover
+  .react-datepicker__navigation--years-previous,
+.react-datepicker__month-option:hover
+  .react-datepicker__navigation--years-previous,
+.react-datepicker__month-year-option:hover
+  .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;
+  border: 0;
+  outline: 0;
+  padding: 0px 6px 0px 0px;
+  position: absolute;
+  top: 0;
+  right: 0;
+  height: 100%;
+  display: table-cell;
+  vertical-align: middle;
+}
+.react-datepicker__close-icon::after {
+  cursor: pointer;
+  background-color: #949eff;
+  color: #fff;
+  border-radius: 50%;
+  height: 16px;
+  width: 16px;
+  padding: 2px;
+  font-size: 12px;
+  line-height: 1;
+  text-align: center;
+  display: table-cell;
+  vertical-align: middle;
+  content: "\00d7";
+}
+.react-datepicker__today-button {
+  background: #26292e;
+  border-top: 1px solid #aeaeae;
+  cursor: pointer;
+  text-align: center;
+  font-weight: bold;
+  padding: 5px 0;
+  clear: left;
+}
+.react-datepicker__portal {
+  position: fixed;
+  width: 100vw;
+  height: 100vh;
+  background-color: rgba(0, 0, 0, 0.8);
+  left: 0;
+  top: 0;
+  justify-content: center;
+  align-items: center;
+  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,
+  .react-datepicker__portal .react-datepicker__time-name {
+    width: 2rem;
+    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;
+  cursor: default;
+}

+ 1 - 1
dashboard/src/components/porter-form/PorterForm.tsx

@@ -254,7 +254,7 @@ const StyledPorterForm = styled.div<{ showSave?: boolean }>`
   height: ${(props) => (props.showSave ? "calc(100% - 50px)" : "100%")};
   background: #ffffff11;
   color: #ffffff;
-  padding: 0px 35px 25px;
+  padding: 0px 35px 20px;
   position: relative;
   border-radius: 8px;
   font-size: 13px;

+ 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) => {
+            onTabChange(newTab);
+            setCurrentTab(newTab);
+          }}
           isLaunch={isLaunch}
           hideSpacer={hideBottomSpacer}
           redirectTabAfterSave={redirectTabAfterSave}

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

@@ -7,6 +7,7 @@ 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":

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

+ 1 - 3
dashboard/src/main/home/cluster-dashboard/dashboard/events/EventsTab.tsx

@@ -3,7 +3,6 @@ import styled from "styled-components";
 import EventCard from "components/events/EventCard";
 import Loading from "components/Loading";
 import InfiniteScroll from "react-infinite-scroll-component";
-import Dropdown from "components/Dropdown";
 import { useKubeEvents } from "components/events/useEvents";
 import SubEventsList from "components/events/SubEventsList";
 
@@ -39,8 +38,7 @@ const EventsTab = () => {
       <Placeholder>
         <div>
           <Header>We couldn't detect the Porter agent on your cluster</Header>
-          In order to use the events tab, you need to install the Porter agent
-          on your cluster.
+          In order to use the events tab, you need to install the Porter agent.
           <InstallPorterAgentButton onClick={() => triggerInstall()}>
             <i className="material-icons">add</i> Install Porter agent
           </InstallPorterAgentButton>

+ 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 - 214
dashboard/src/main/home/cluster-dashboard/dashboard/incidents/IncidentsTab.tsx

@@ -1,214 +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 = () => {
-  const { currentProject, currentCluster } = useContext(Context);
-  const [isAgentInstalled, setIsAgentInstalled] = useState(false);
-  const [isAgentOutdated, setIsAgentOutdated] = useState(false);
-  const [isLoading, setIsLoading] = useState(true);
-
-  useEffect(() => {
-    api
-      .detectPorterAgent<DetectAgentResponse>(
-        "<token>",
-        {},
-        {
-          project_id: currentProject.id,
-          cluster_id: currentCluster.id,
-        }
-      )
-      .then((res) => res.data)
-      .then((data) => {
-        if (data.version === "v1") {
-          setIsAgentInstalled(true);
-          setIsAgentOutdated(true);
-        } else {
-          setIsAgentInstalled(true);
-          setIsAgentOutdated(false);
-        }
-      })
-      .catch(() => {
-        setIsAgentInstalled(false);
-      })
-      .finally(() => {
-        setIsLoading(false);
-      });
-  }, []);
-
-  const upgradeAgent = async () => {
-    const project_id = currentProject?.id;
-    const cluster_id = currentCluster?.id;
-    try {
-      await api.upgradePorterAgent(
-        "<token>",
-        {},
-        {
-          project_id,
-          cluster_id,
-        }
-      );
-      setIsAgentOutdated(false);
-    } catch (err) {
-      setIsAgentOutdated(true);
-    }
-  };
-
-  const installAgent = async () => {
-    const project_id = currentProject?.id;
-    const cluster_id = currentCluster?.id;
-
-    api
-      .installPorterAgent("<token>", {}, { project_id, cluster_id })
-      .then(() => {
-        setIsAgentInstalled(true);
-      })
-      .catch(() => {
-        setIsAgentInstalled(false);
-      });
-  };
-
-  const triggerInstall = () => {
-    if (isAgentOutdated) {
-      upgradeAgent();
-      return;
-    }
-
-    installAgent();
-  };
-
-  if (isLoading) {
-    return (
-      <StyledCard>
-        <Loading height="200px" />
-      </StyledCard>
-    );
-  }
-
-  if (!isAgentInstalled || isAgentOutdated) {
-    return (
-      <Placeholder>
-        <AgentButtonContainer>
-          <Header>Incident detection is not enabled on this cluster.</Header>
-          <Subheader>
-            In order to view incidents, you must enable incident detection on
-            this cluster.
-          </Subheader>
-          <InstallPorterAgentButton onClick={() => triggerInstall()}>
-            <i className="material-icons">add</i> Enable Incident Detection
-          </InstallPorterAgentButton>
-        </AgentButtonContainer>
-      </Placeholder>
-    );
-  }
-
-  return (
-    <StyledCard>
-      <IncidentsTable />
-    </StyledCard>
-  );
-};
-
-export default IncidentsTab;
-
-const StyledCard = styled.div`
-  margin-top: 35px;
-  background: #26282f;
-  padding: 14px;
-  border-radius: 8px;
-  box-shadow: 0 4px 15px 0px #00000055;
-  position: relative;
-  border: 2px solid #9eb4ff00;
-  width: 100%;
-  :not(:last-child) {
-    margin-bottom: 25px;
-  }
-`;
-
-const InstallPorterAgentButton = styled.button`
-  display: flex;
-  flex-direction: row;
-  align-items: center;
-  justify-content: space-between;
-  font-size: 13px;
-  width: 200px;
-  cursor: pointer;
-  font-family: "Work Sans", sans-serif;
-  border: none;
-  border-radius: 5px;
-  color: white;
-  height: 35px;
-  padding: 0px 8px;
-  padding-bottom: 1px;
-  margin-top: 20px;
-  font-weight: 500;
-  padding-right: 15px;
-  overflow: hidden;
-  white-space: nowrap;
-  text-overflow: ellipsis;
-  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 - 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;
-`;

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

@@ -14,6 +14,7 @@ import RevisionSection from "./RevisionSection";
 import ValuesYaml from "./ValuesYaml";
 import GraphSection from "./GraphSection";
 import MetricsSection from "./metrics/MetricsSection";
+import LogsSection, { InitLogData } from "./logs-section/LogsSection";
 import ListSection from "./ListSection";
 import StatusSection from "./status/StatusSection";
 import SettingsSection from "./SettingsSection";
@@ -22,10 +23,11 @@ import { useWebsockets } from "shared/hooks/useWebsockets";
 import useAuth from "shared/auth/useAuth";
 import TitleSection from "components/TitleSection";
 import DeploymentType from "./DeploymentType";
-import IncidentsTab from "./incidents/IncidentsTab";
+import EventsTab from "./events/EventsTab";
 import BuildSettingsTab from "./build-settings/BuildSettingsTab";
 import { DisabledNamespacesForIncidents } from "./incidents/DisabledNamespaces";
 import { useStackEnvGroups } from "./useStackEnvGroups";
+import DeployStatusSection from "./deploy-status-section/DeployStatusSection";
 
 type Props = {
   namespace: string;
@@ -37,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",
   });
@@ -71,9 +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,
@@ -95,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);
@@ -131,6 +141,7 @@ const ExpandedChart: React.FC<Props> = (props) => {
   };
 
   const getControllers = async (chart: ChartType) => {
+    
     // don't retrieve controllers for chart that failed to even deploy.
     if (chart.info.status == "failed") return;
 
@@ -171,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") {
@@ -238,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,
     });
 
@@ -355,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,
       });
 
@@ -389,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;
@@ -409,20 +420,32 @@ 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
+            currentChart={chart}
+            isFullscreen={isFullscreen}
+            setIsFullscreen={setIsFullscreen}
+            initData={logData}
+            setInitData={setLogData}
+          />
+        );
       case "metrics":
         return <MetricsSection currentChart={chart} />;
-      case "incidents":
+      case "events":
         if (DisabledNamespacesForIncidents.includes(currentChart.namespace)) {
           return null;
         }
         return (
-          <IncidentsTab
-            releaseName={chart?.name}
-            namespace={chart?.namespace}
-          />
+          <EventsTab currentChart={chart} setLogData={renderLogsAtTimestamp} />
         );
       case "status":
         if (isLoadingChartData) {
@@ -525,13 +548,20 @@ const ExpandedChart: React.FC<Props> = (props) => {
     // Collate non-form tabs
     let rightTabOptions = [] as any[];
     let leftTabOptions = [] as any[];
-    leftTabOptions.push({ label: "Status", value: "status" });
+    if (
+      currentChart.chart.metadata.home === "https://getporter.dev/" &&
+      (currentChart.chart.metadata.name === "web" ||
+        currentChart.chart.metadata.name === "worker" ||
+        currentChart.chart.metadata.name === "job") &&
+      currentCluster.agent_integration_enabled
+    ) {
+      leftTabOptions.push({ label: "Events", value: "events" });
 
-    /* Temporarily disable incident detection
-    if (!DisabledNamespacesForIncidents.includes(currentChart.namespace)) {
-      leftTabOptions.push({ label: "Incidents", value: "incidents" });
+      if (isAgentInstalled) {
+        leftTabOptions.push({ label: "Logs", value: "logs" });
+      }
     }
-    */
+    leftTabOptions.push({ label: "Status", value: "status" });
 
     if (props.isMetricsInstalled) {
       leftTabOptions.push({ label: "Metrics", value: "metrics" });
@@ -560,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)
       );
@@ -574,6 +604,13 @@ const ExpandedChart: React.FC<Props> = (props) => {
   };
 
   const setRevision = (chart: ChartType, isCurrent?: boolean) => {
+    // if we've set the revision, we also override the revision in log data
+    let newLogData = logData;
+
+    newLogData.revision = `${chart.version}`;
+
+    setLogData(newLogData);
+
     setIsPreview(!isCurrent);
     getChartData(chart);
   };
@@ -586,9 +623,9 @@ const ExpandedChart: React.FC<Props> = (props) => {
   const renderUrl = () => {
     if (url) {
       return (
-        <Url href={url} target="_blank">
+        <Url>
           <i className="material-icons">link</i>
-          {url}
+          <a href={url} target="_blank">{url}</a>
         </Url>
       );
     }
@@ -679,6 +716,58 @@ 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
+        .getChart(
+          "<token>",
+          {},
+          {
+            id: currentProject.id,
+            namespace: props.currentChart.namespace,
+            cluster_id: currentCluster.id,
+            name: props.currentChart.name,
+            revision: parseInt(logData.revision),
+          }
+        )
+        .then((res) => {
+          setCurrentChart(res.data || props.currentChart);
+        })
+        .catch(console.log);
+
+      return;
+    }
+
+    setCurrentChart(props.currentChart);
+  }, [logData, props.currentChart]);
+
   useEffect(() => {
     window.analytics?.track("Opened Chart", {
       chart: currentChart.name,
@@ -705,7 +794,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;
@@ -756,134 +845,162 @@ const ExpandedChart: React.FC<Props> = (props) => {
           setFullScreenLogs={() => setFullScreenLogs(false)}
         />
       ) : (
-        <StyledExpandedChart>
-          <BreadcrumbRow>
-            <Breadcrumb onClick={props.closeChart}>
-              <ArrowIcon src={leftArrow} />
-              <Wrap>Back</Wrap>
-            </Breadcrumb>
-          </BreadcrumbRow>
-          <HeaderWrapper>
-            <TitleSection
-              icon={currentChart.chart.metadata.icon}
-              iconWidth="33px"
-            >
-              {currentChart.name}
-              <DeploymentType currentChart={currentChart} />
-              <TagWrapper>
-                Namespace <NamespaceTag>{currentChart.namespace}</NamespaceTag>
-              </TagWrapper>
-            </TitleSection>
-
-            {currentChart.chart.metadata.name != "worker" &&
-              currentChart.chart.metadata.name != "job" &&
-              renderUrl()}
-            <InfoWrapper>
-              <StatusIndicator
-                controllers={controllers}
-                status={currentChart.info.status}
-                margin_left={"0px"}
-              />
-              <LastDeployed>
-                <Dot>•</Dot>Last deployed
-                {" " + getReadableDate(currentChart.info.last_deployed)}
-              </LastDeployed>
-            </InfoWrapper>
-          </HeaderWrapper>
-          {deleting ? (
-            <>
-              <LineBreak />
-              <Placeholder>
-                <TextWrap>
-                  <Header>
-                    <Spinner src={loadingSrc} /> Deleting "{currentChart.name}"
-                  </Header>
-                  You will be automatically redirected after deletion is
-                  complete.
-                </TextWrap>
-              </Placeholder>
-            </>
+        <>
+          {isFullscreen ? (
+            <LogsSection
+              isFullscreen={true}
+              setIsFullscreen={setIsFullscreen}
+              currentChart={currentChart}
+              setInitData={() => {}}
+            />
           ) : (
-            <>
-              <RevisionSection
-                showRevisions={showRevisions}
-                toggleShowRevisions={() => {
-                  setShowRevisions(!showRevisions);
-                }}
-                chart={currentChart}
-                refreshChart={() => getChartData(currentChart)}
-                setRevision={setRevision}
-                forceRefreshRevisions={forceRefreshRevisions}
-                refreshRevisionsOff={() => setForceRefreshRevisions(false)}
-                shouldUpdate={
-                  currentChart.latest_version &&
-                  currentChart.latest_version !==
-                    currentChart.chart.metadata.version
-                }
-                latestVersion={currentChart.latest_version}
-                upgradeVersion={handleUpgradeVersion}
-              />
-              {isStack && isLoadingStackEnvGroups ? (
+            <StyledExpandedChart>
+              <BreadcrumbRow>
+                <Breadcrumb onClick={props.closeChart}>
+                  <ArrowIcon src={leftArrow} />
+                  <Wrap>Back</Wrap>
+                </Breadcrumb>
+              </BreadcrumbRow>
+              <HeaderWrapper>
+                <TitleSection
+                  icon={currentChart.chart.metadata.icon}
+                  iconWidth="33px"
+                >
+                  {currentChart.name}
+                  <DeploymentType currentChart={currentChart} />
+                  <TagWrapper>
+                    Namespace{" "}
+                    <NamespaceTag>{currentChart.namespace}</NamespaceTag>
+                  </TagWrapper>
+                </TitleSection>
+
+                {currentChart.chart.metadata.name != "worker" &&
+                  currentChart.chart.metadata.name != "job" &&
+                  renderUrl()}
+                <InfoWrapper>
+                  {/*
+                  <StatusIndicator
+                    controllers={controllers}
+                    status={currentChart.info.status}
+                    margin_left={"0px"}
+                  />
+                  */}
+                  <DeployStatusSection chart={currentChart} setLogData={renderLogsAtTimestamp} />
+                  <LastDeployed>
+                    <Dot>•</Dot>Last deployed
+                    {" " + getReadableDate(currentChart.info.last_deployed)}
+                  </LastDeployed>
+                </InfoWrapper>
+              </HeaderWrapper>
+              {deleting ? (
                 <>
                   <LineBreak />
                   <Placeholder>
                     <TextWrap>
                       <Header>
-                        <Spinner src={loadingSrc} />
+                        <Spinner src={loadingSrc} /> Deleting "
+                        {currentChart.name}"
                       </Header>
+                      You will be automatically redirected after deletion is
+                      complete.
                     </TextWrap>
                   </Placeholder>
                 </>
               ) : (
                 <>
-                  {(isPreview || leftTabOptions.length > 0) && (
-                    <BodyWrapper>
-                      <PorterFormWrapper
-                        formData={cloneDeep(currentChart.form)}
-                        valuesToOverride={{
-                          namespace: props.namespace,
-                          clusterId: currentCluster.id,
-                        }}
-                        renderTabContents={renderTabContents}
-                        isReadOnly={
-                          isPreview ||
-                          imageIsPlaceholder ||
-                          !isAuthorized("application", "", ["get", "update"])
-                        }
-                        onSubmit={onSubmit}
-                        includeMetadata
-                        rightTabOptions={rightTabOptions}
-                        leftTabOptions={leftTabOptions}
-                        color={isPreview ? "#f5cb42" : null}
-                        addendum={
-                          <TabButton
-                            onClick={toggleDevOpsMode}
-                            devOpsMode={devOpsMode}
-                          >
-                            <i className="material-icons">offline_bolt</i>{" "}
-                            DevOps Mode
-                          </TabButton>
-                        }
-                        saveValuesStatus={saveValuesStatus}
-                        injectedProps={{
-                          "key-value-array": {
-                            availableSyncEnvGroups:
-                              isStack && !isPreview
-                                ? stackEnvGroups
-                                : undefined,
-                          },
-                          "url-link": {
-                            chart: currentChart,
-                          },
-                        }}
-                      />
-                    </BodyWrapper>
+                  <RevisionSection
+                    showRevisions={showRevisions}
+                    toggleShowRevisions={() => {
+                      setShowRevisions(!showRevisions);
+                    }}
+                    chart={currentChart}
+                    refreshChart={() => getChartData(currentChart)}
+                    setRevision={setRevision}
+                    forceRefreshRevisions={forceRefreshRevisions}
+                    refreshRevisionsOff={() => setForceRefreshRevisions(false)}
+                    shouldUpdate={
+                      currentChart.latest_version &&
+                      currentChart.latest_version !==
+                        currentChart.chart.metadata.version
+                    }
+                    latestVersion={currentChart.latest_version}
+                    upgradeVersion={handleUpgradeVersion}
+                  />
+                  {isStack && isLoadingStackEnvGroups ? (
+                    <>
+                      <LineBreak />
+                      <Placeholder>
+                        <TextWrap>
+                          <Header>
+                            <Spinner src={loadingSrc} />
+                          </Header>
+                        </TextWrap>
+                      </Placeholder>
+                    </>
+                  ) : (
+                    <>
+                      {(isPreview || leftTabOptions.length > 0) && (
+                        <BodyWrapper>
+                          <PorterFormWrapper
+                            formData={cloneDeep(currentChart.form)}
+                            valuesToOverride={{
+                              namespace: props.namespace,
+                              clusterId: currentCluster.id,
+                            }}
+                            renderTabContents={renderTabContents}
+                            isReadOnly={
+                              isPreview ||
+                              imageIsPlaceholder ||
+                              !isAuthorized("application", "", [
+                                "get",
+                                "update",
+                              ])
+                            }
+                            onSubmit={onSubmit}
+                            includeMetadata
+                            rightTabOptions={rightTabOptions}
+                            leftTabOptions={leftTabOptions}
+                            color={isPreview ? "#f5cb42" : null}
+                            addendum={
+                              <TabButton
+                                onClick={toggleDevOpsMode}
+                                devOpsMode={devOpsMode}
+                              >
+                                <i className="material-icons">offline_bolt</i>{" "}
+                                DevOps Mode
+                              </TabButton>
+                            }
+                            saveValuesStatus={saveValuesStatus}
+                            injectedProps={{
+                              "key-value-array": {
+                                availableSyncEnvGroups:
+                                  isStack && !isPreview
+                                    ? stackEnvGroups
+                                    : undefined,
+                              },
+                              "url-link": {
+                                chart: currentChart,
+                              },
+                            }}
+                            overrideCurrentTab={overrideCurrentTab}
+                            onTabChange={(newTab) => {
+                              if (newTab !== "logs") {
+                                setOverrideCurrentTab("");
+                                setLogData({
+                                  revision: `${currentChart.version}`,
+                                });
+                              }
+                            }}
+                          />
+                        </BodyWrapper>
+                      )}
+                    </>
                   )}
                 </>
               )}
-            </>
+            </StyledExpandedChart>
           )}
-        </StyledExpandedChart>
+        </>
       )}
     </>
   );
@@ -936,7 +1053,8 @@ const LineBreak = styled.div`
 
 const BodyWrapper = styled.div`
   position: relative;
-  margin-bottom: 50px;
+  padding-bottom: 0;
+  margin-bottom: 0;
 `;
 
 const Header = styled.div`
@@ -975,15 +1093,16 @@ const Bolded = styled.div`
   margin-right: 6px;
 `;
 
-const Url = styled.a`
+const Url = styled.div`
   display: block;
-  margin-left: 2px;
+  margin-left: 5px;
   font-size: 13px;
   margin-top: 16px;
   user-select: all;
   margin-bottom: -5px;
   user-select: text;
   display: flex;
+  color: #949eff;
   align-items: center;
 
   > i {
@@ -1026,7 +1145,7 @@ const HeaderWrapper = styled.div`
 `;
 
 const Dot = styled.div`
-  margin-right: 9px;
+  margin-right: 16px;
 `;
 
 const InfoWrapper = styled.div`
@@ -1038,7 +1157,7 @@ const InfoWrapper = styled.div`
 
 const LastDeployed = styled.div`
   font-size: 13px;
-  margin-left: 10px;
+  margin-left: 8px;
   margin-top: -1px;
   display: flex;
   align-items: center;

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

+ 5 - 7
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 = () => {
@@ -500,7 +498,7 @@ const StyledRevisionSection = styled.div`
   max-height: ${(props: { showRevisions: boolean }) =>
     props.showRevisions ? "255px" : "40px"};
   background: #ffffff11;
-  margin: 25px 0px 18px;
+  margin: 20px 0px 18px;
   overflow: hidden;
   border-radius: 8px;
   animation: ${(props: { showRevisions: boolean }) =>

+ 393 - 0
dashboard/src/main/home/cluster-dashboard/expanded-chart/deploy-status-section/ControllerTab.tsx

@@ -0,0 +1,393 @@
+import React, { useContext, useEffect, useMemo, useState } from "react";
+import styled from "styled-components";
+import api from "shared/api";
+import { Context } from "shared/Context";
+import ResourceTab from "./ResourceTab";
+import ConfirmOverlay from "components/ConfirmOverlay";
+import { NewWebsocketOptions, useWebsockets } from "shared/hooks/useWebsockets";
+import PodRow from "./PodRow";
+import { timeFormat } from "d3-time-format";
+import { getAvailability, getPodStatus } from "./util";
+import _ from "lodash";
+
+type Props = {
+  controller: any;
+  selectedPod: any;
+  selectPod: (newPod: any, userSelected: boolean) => unknown;
+  selectors: any;
+  isLast?: boolean;
+  isFirst?: boolean;
+  setPodError: (x: string) => void;
+  onUpdate: (update: any) => void;
+};
+
+// Controller tab in log section that displays list of pods on click.
+export type ControllerTabPodType = {
+  namespace: string;
+  name: string;
+  phase: string;
+  status: any;
+  replicaSetName: string;
+  restartCount: number | string;
+  podAge: string;
+  revisionNumber?: number;
+  containerStatus: any;
+};
+
+const formatCreationTimestamp = timeFormat("%H:%M:%S %b %d, '%y");
+
+const ControllerTabFC: React.FunctionComponent<Props> = ({
+  controller,
+  selectPod,
+  isFirst,
+  isLast,
+  selectors,
+  setPodError,
+  selectedPod,
+  onUpdate,
+}) => {
+  const [pods, setPods] = useState<ControllerTabPodType[]>([]);
+  const [rawPodList, setRawPodList] = useState<any[]>([]);
+  const [podPendingDelete, setPodPendingDelete] = useState<any>(null);
+  const [available, setAvailable] = useState<number>(null);
+  const [total, setTotal] = useState<number>(null);
+  const [userSelectedPod, setUserSelectedPod] = useState<boolean>(false);
+
+  const { currentCluster, currentProject, setCurrentError } = useContext(
+    Context
+  );
+  const {
+    newWebsocket,
+    openWebsocket,
+    closeAllWebsockets,
+    closeWebsocket,
+  } = useWebsockets();
+
+  const currentSelectors = useMemo(() => {
+    if (controller.kind.toLowerCase() == "job" && selectors) {
+      return [...selectors];
+    }
+    let newSelectors = [] as string[];
+    let ml =
+      controller?.spec?.selector?.matchLabels || controller?.spec?.selector;
+    let i = 1;
+    let selector = "";
+    for (var key in ml) {
+      selector += key + "=" + ml[key];
+      if (i != Object.keys(ml).length) {
+        selector += ",";
+      }
+      i += 1;
+    }
+    newSelectors.push(selector);
+    return [...newSelectors];
+  }, [controller, selectors]);
+
+  useEffect(() => {
+    updatePods();
+    [controller?.kind, "pod"].forEach((kind) => {
+      setupWebsocket(kind, controller?.metadata?.uid);
+    });
+    () => closeAllWebsockets();
+  }, [currentSelectors, controller, currentCluster, currentProject]);
+
+  const updatePods = async () => {
+    try {
+      const res = await api.getMatchingPods(
+        "<token>",
+        {
+          namespace: controller?.metadata?.namespace,
+          selectors: currentSelectors,
+        },
+        {
+          id: currentProject.id,
+          cluster_id: currentCluster.id,
+        }
+      );
+      const data = res?.data as any[];
+      let newPods = data
+        // Parse only data that we need
+        .map<ControllerTabPodType>((pod: any) => {
+          const replicaSetName =
+            Array.isArray(pod?.metadata?.ownerReferences) &&
+            pod?.metadata?.ownerReferences[0]?.name;
+          const containerStatus =
+            Array.isArray(pod?.status?.containerStatuses) &&
+            pod?.status?.containerStatuses[0];
+
+          const restartCount = containerStatus
+            ? containerStatus.restartCount
+            : "N/A";
+
+          const podAge = formatCreationTimestamp(
+            new Date(pod?.metadata?.creationTimestamp)
+          );
+
+          return {
+            namespace: pod?.metadata?.namespace,
+            name: pod?.metadata?.name,
+            phase: pod?.status?.phase,
+            status: pod?.status,
+            replicaSetName,
+            restartCount,
+            containerStatus,
+            podAge: pod?.metadata?.creationTimestamp ? podAge : "N/A",
+            revisionNumber:
+              (pod?.metadata?.annotations &&
+                pod?.metadata?.annotations["helm.sh/revision"]) ||
+              "N/A",
+          };
+        });
+
+      setPods(newPods);
+      setRawPodList(data);
+      // If the user didn't click a pod, select the first returned from list.
+      if (!userSelectedPod) {
+        let status = getPodStatus(newPods[0].status);
+        status === "failed" &&
+          newPods[0].status?.message &&
+          setPodError(newPods[0].status?.message);
+        handleSelectPod(newPods[0], data);
+      }
+    } catch (error) {}
+  };
+
+  /**
+   * handleSelectPod is a wrapper for the selectPod function received from parent.
+   * Internally we use the ControllerPodType but we want to pass to the parent the
+   * raw pod returned from the API.
+   *
+   * @param pod A ControllerPodType pod that will be used to search the raw pod to pass
+   * @param rawList A rawList of pods in case we don't want to use the state one. Useful to
+   * avoid problems with reactivity
+   */
+  const handleSelectPod = (
+    pod: ControllerTabPodType,
+    rawList?: any[],
+    userSelected?: boolean
+  ) => {
+    console.log(rawPodList, rawList, !!userSelected);
+
+    const rawPod = [...rawPodList, ...(rawList || [])].find(
+      (rawPod) => rawPod?.metadata?.name === pod?.name
+    );
+    selectPod(rawPod, !!userSelected);
+  };
+
+  const currentSelectedPod = useMemo(() => {
+    const pod = selectedPod;
+    const replicaSetName =
+      Array.isArray(pod?.metadata?.ownerReferences) &&
+      pod?.metadata?.ownerReferences[0]?.name;
+    return {
+      namespace: pod?.metadata?.namespace,
+      name: pod?.metadata?.name,
+      phase: pod?.status?.phase,
+      status: pod?.status,
+      replicaSetName,
+    } as ControllerTabPodType;
+  }, [selectedPod]);
+
+  const currentControllerStatus = useMemo(() => {
+    let status = available == total ? "running" : "waiting";
+
+    controller?.status?.conditions?.forEach((condition: any) => {
+      if (
+        condition.type == "Progressing" &&
+        condition.status == "False" &&
+        condition.reason == "ProgressDeadlineExceeded"
+      ) {
+        status = "failed";
+      }
+    });
+
+    if (controller.kind.toLowerCase() === "job" && pods.length == 0) {
+      status = "completed";
+    }
+    return status;
+  }, [controller, available, total, pods]);
+
+  const handleDeletePod = (pod: any) => {
+    api
+      .deletePod(
+        "<token>",
+        {},
+        {
+          cluster_id: currentCluster.id,
+          name: pod?.name,
+          namespace: pod?.namespace,
+          id: currentProject.id,
+        }
+      )
+      .then((res) => {
+        updatePods();
+        setPodPendingDelete(null);
+      })
+      .catch((err) => {
+        setCurrentError(JSON.stringify(err));
+        setPodPendingDelete(null);
+      });
+  };
+
+  const replicaSetArray = useMemo(() => {
+    const podsDividedByReplicaSet = _.sortBy(pods, ["revisionNumber"])
+      .reverse()
+      .reduce<Array<Array<ControllerTabPodType>>>(function (
+        prev,
+        currentPod,
+        i
+      ) {
+        if (
+          !i ||
+          prev[prev.length - 1][0].replicaSetName !== currentPod.replicaSetName
+        ) {
+          return prev.concat([[currentPod]]);
+        }
+        prev[prev.length - 1].push(currentPod);
+        return prev;
+      },
+      []);
+
+    return podsDividedByReplicaSet.length === 1 ? [] : podsDividedByReplicaSet;
+  }, [pods]);
+
+  const setupWebsocket = (kind: string, controllerUid: string) => {
+    let apiEndpoint = `/api/projects/${currentProject.id}/clusters/${currentCluster.id}/${kind}/status?`;
+    if (kind == "pod" && currentSelectors) {
+      apiEndpoint += `selectors=${currentSelectors[0]}`;
+    }
+
+    const options: NewWebsocketOptions = {};
+    options.onopen = () => {
+      console.log("connected to websocket");
+    };
+
+    options.onmessage = (evt: MessageEvent) => {
+      let event = JSON.parse(evt.data);
+      let object = event.Object;
+      object.metadata.kind = event.Kind;
+
+      // Make a new API call to update pods only when the event type is UPDATE
+      if (event.event_type !== "UPDATE") {
+        return;
+      }
+      // update pods no matter what if ws message is a pod event.
+      // If controller event, check if ws message corresponds to the designated controller in props.
+      if (event.Kind != "pod" && object.metadata.uid !== controllerUid) {
+        return;
+      }
+
+      if (event.Kind != "pod") {
+        let [available, total] = getAvailability(object.metadata.kind, object);
+        setAvailable(available);
+        setTotal(total);
+        return;
+      }
+      updatePods();
+    };
+
+    options.onclose = () => {
+      console.log("closing websocket");
+    };
+
+    options.onerror = (err: ErrorEvent) => {
+      console.log(err);
+      closeWebsocket(kind);
+    };
+
+    newWebsocket(kind, apiEndpoint, options);
+    openWebsocket(kind);
+  };
+
+  const mapPods = (podList: ControllerTabPodType[]) => {
+    return podList.map((pod, i, arr) => {
+      let status = getPodStatus(pod.status);
+      return (
+        <PodRow
+          key={i}
+          pod={pod}
+          isSelected={currentSelectedPod?.name === pod?.name}
+          podStatus={status}
+          isLastItem={i === arr.length - 1}
+          onTabClick={() => {
+            setPodError("");
+            status === "failed" &&
+              pod.status?.message &&
+              setPodError(pod.status?.message);
+            handleSelectPod(pod, [], true);
+            setUserSelectedPod(true);
+          }}
+          onDeleteClick={(e: MouseEvent) => {
+            e.preventDefault();
+            e.stopPropagation();
+            setPodPendingDelete(pod);
+          }}
+        />
+      );
+    });
+  };
+
+  useEffect(() => {
+    onUpdate({ pods, available, total, replicaSetArray });
+  }, [pods, replicaSetArray, available, total]);
+
+  return (
+    <ResourceTab
+      label={controller.kind}
+      // handle CronJob case
+      name={controller.metadata?.name || controller.name}
+      status={{ label: currentControllerStatus, available, total }}
+      isLast={isLast}
+      expanded={isFirst}
+    >
+      {!!replicaSetArray.length &&
+        replicaSetArray.map((subArray, index) => {
+          const firstItem = subArray[0];
+          return (
+            <div key={firstItem.replicaSetName + index}>
+              <ReplicaSetContainer>
+                <ReplicaSetName>
+                  {firstItem?.revisionNumber &&
+                    firstItem?.revisionNumber.toString() != "N/A" && (
+                      <Bold>Revision {firstItem.revisionNumber}:</Bold>
+                    )}{" "}
+                  {firstItem.replicaSetName}
+                </ReplicaSetName>
+              </ReplicaSetContainer>
+              {mapPods(subArray)}
+            </div>
+          );
+        })}
+      {!replicaSetArray.length && mapPods(pods)}
+      <ConfirmOverlay
+        message="Are you sure you want to delete this pod?"
+        show={podPendingDelete}
+        onYes={() => handleDeletePod(podPendingDelete)}
+        onNo={() => setPodPendingDelete(null)}
+      />
+    </ResourceTab>
+  );
+};
+
+export default ControllerTabFC;
+
+const Bold = styled.span`
+  font-weight: 500;
+  display: inline;
+  color: #ffffff;
+`;
+
+const ReplicaSetContainer = styled.div`
+  padding: 10px 5px;
+  display: flex;
+  overflow-wrap: anywhere;
+  justify-content: space-between;
+`;
+
+const ReplicaSetName = styled.span`
+  padding-left: 10px;
+  overflow-wrap: anywhere;
+  max-width: calc(100% - 45px);
+  line-height: 1.5em;
+  color: #ffffff33;
+`;

+ 219 - 0
dashboard/src/main/home/cluster-dashboard/expanded-chart/deploy-status-section/DeployStatusSection.tsx

@@ -0,0 +1,219 @@
+import React, { useState, useRef, useEffect } from "react";
+import PodDropdown from "./PodDropdown";
+
+import styled from "styled-components";
+import { getPodStatus } from "./util";
+import { InitLogData } from "../logs-section/LogsSection";
+
+type Props = {
+  chart?: any;
+  setLogData: (initLogData: InitLogData) => void;
+};
+
+type DeployStatus = "Deploying" | "Deployed" | "Failed";
+
+const DeployStatusSection: React.FC<Props> = (props) => {
+  const [status, setStatus] = useState<DeployStatus>("Deployed");
+  const [percentage, setPercentage] = useState("0%");
+  const [isExpanded, setIsExpanded] = useState(false);
+
+  const wrapperRef = useRef<HTMLInputElement>(null);
+  const parentRef = useRef<HTMLInputElement>(null);
+
+  useEffect(() => {
+    document.addEventListener("mousedown", handleClickOutside.bind(this));
+    return () =>
+      document.removeEventListener("mousedown", handleClickOutside.bind(this));
+  }, []);
+
+  const handleClickOutside = (event: any) => {
+    if (
+      wrapperRef &&
+      wrapperRef.current &&
+      !wrapperRef.current.contains(event.target) &&
+      parentRef &&
+      parentRef.current &&
+      !parentRef.current.contains(event.target)
+    ) {
+      setIsExpanded(false);
+    }
+  };
+
+  const onUpdate = (props: any) => {
+    const { available, total, replicaSetArray } = props;
+    let pods = props.pods;
+
+    if (total) {
+      const remaining = (total - available) / props.total;
+      setPercentage(Math.floor(remaining * 100) + "%");
+    }
+
+    if (replicaSetArray.length) {
+      pods = replicaSetArray[0];
+    }
+
+    const podStatuses = pods.map((pod: any) => getPodStatus(pod.status));
+
+    if (
+      podStatuses.every((status: string) =>
+        ["running", "Ready", "completed", "Completed"].includes(status)
+      )
+    ) {
+      setStatus("Deployed");
+      return;
+    }
+
+    if (
+      podStatuses.some((status: string) =>
+        ["failed", "failedValidation"].includes(status)
+      )
+    ) {
+      setStatus("Failed");
+      return;
+    }
+
+    setStatus("Deploying");
+  };
+
+  return (
+    <>
+      <StyledDeployStatusSection
+        onClick={() => setIsExpanded(!isExpanded)}
+        ref={parentRef}
+        isExpanded={isExpanded}
+      >
+        {status === "Deploying" ? (
+          <>
+            <StatusCircle percentage={percentage} />
+            {status}
+          </>
+        ) : (
+          <StatusWrapper>
+            <StatusColor status={status} />
+            {status}
+          </StatusWrapper>
+        )}
+      </StyledDeployStatusSection>
+      <DropdownWrapper expanded={isExpanded}>
+        <Dropdown ref={wrapperRef}>
+          <PodDropdown
+            currentChart={props.chart}
+            onUpdate={onUpdate}
+            // Allow users to navigate to pod logs upon clicking the pod
+            onSelectPod={(pod: any) => {
+              console.log(
+                "SET LOG DATA",
+                pod?.metadata?.name,
+                pod?.metadata?.annotations?.["helm.sh/revision"]
+              );
+
+              props.setLogData({
+                podName: pod?.metadata?.name,
+                revision: pod?.metadata?.annotations?.["helm.sh/revision"],
+              });
+            }}
+          />
+        </Dropdown>
+      </DropdownWrapper>
+    </>
+  );
+};
+
+export default DeployStatusSection;
+
+const StatusCircle = styled.div<{ percentage?: any }>`
+  width: 16px;
+  height: 16px;
+  border-radius: 50%;
+  margin-right: 10px;
+  background: conic-gradient(
+    from 0deg,
+    #ffffff33 ${(props) => props.percentage},
+    #ffffffaa 0% ${(props) => props.percentage}
+  );
+`;
+
+const DropdownWrapper = styled.div<{
+  dropdownAlignRight?: boolean;
+  expanded?: boolean;
+}>`
+  display: ${(props) => (props.expanded ? "block" : "none")};
+  position: absolute;
+  left: ${(props) => (props.dropdownAlignRight ? "" : "0")};
+  right: ${(props) => (props.dropdownAlignRight ? "0" : "")};
+  z-index: 5;
+  top: calc(100% + 7px);
+  width: 35%;
+  min-width: 400px;
+  animation: floatIn 0.2s;
+  animation-timing-function: ease-out;
+  animation-fill-mode: forwards;
+  @keyframes floatIn {
+    from {
+      opacity: 0;
+      transform: translateY(10px);
+    }
+    to {
+      opacity: 1;
+      transform: translateY(0px);
+    }
+  }
+`;
+
+const Dropdown = styled.div`
+  z-index: 999;
+  overflow-y: auto;
+  background: #2f3135;
+  padding: 0;
+  border-radius: 5px;
+  border: 1px solid #aaaabb33;
+`;
+
+const StyledDeployStatusSection = styled.div<{ isExpanded?: boolean }>`
+  font-size: 13px;
+  height: 30px;
+  border-radius: 5px;
+  padding: 0 9px;
+  padding-left: 7px;
+  display: flex;
+  margin-left: -1px;
+  align-items: center;
+  ${(props) =>
+    props.isExpanded &&
+    `
+  background: #26292e;
+  border: 1px solid #494b4f;
+  border: 1px solid #7a7b80;
+  margin-left: -2px;
+  margin-right: -1px;
+  `}
+  justify-content: center;
+  cursor: pointer;
+  :hover {
+    background: #26292e;
+    border: 1px solid #494b4f;
+    border: 1px solid #7a7b80;
+    margin-left: -2px;
+    margin-right: -1px;
+  }
+`;
+
+const StatusWrapper = styled.div`
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  gap: 10px;
+`;
+
+const StatusColor = styled.div`
+  width: 8px;
+  min-width: 8px;
+  height: 8px;
+  background: ${(props: { status: DeployStatus }) =>
+    props.status === "Deployed"
+      ? "#4797ff"
+      : props.status === "Failed"
+      ? "#ed5f85"
+      : "#f5cb42"};
+  border-radius: 20px;
+`;

+ 149 - 0
dashboard/src/main/home/cluster-dashboard/expanded-chart/deploy-status-section/PodDropdown.tsx

@@ -0,0 +1,149 @@
+import React, { useContext, useEffect, useState } from "react";
+import styled from "styled-components";
+
+import api from "shared/api";
+import { Context } from "shared/Context";
+import { ChartType } from "shared/types";
+import Loading from "components/Loading";
+
+import ControllerTab from "./ControllerTab";
+
+type Props = {
+  selectors?: string[];
+  currentChart: ChartType;
+  onUpdate: (props: any) => void;
+  onSelectPod: (pod: any) => void;
+};
+
+const PodDropdown: React.FunctionComponent<Props> = ({
+  currentChart,
+  selectors,
+  onUpdate,
+  onSelectPod,
+}) => {
+  const [selectedPod, setSelectedPod] = useState<any>({});
+  const [controllers, setControllers] = useState<any[]>([]);
+  const [isLoading, setIsLoading] = useState<boolean>(true);
+  const [podError, setPodError] = useState<string>("");
+
+  const { currentProject, currentCluster, setCurrentError } = useContext(
+    Context
+  );
+
+  useEffect(() => {
+    let isSubscribed = true;
+    api
+      .getChartControllers(
+        "<token>",
+        {},
+        {
+          namespace: currentChart.namespace,
+          cluster_id: currentCluster.id,
+          id: currentProject.id,
+          name: currentChart.name,
+          revision: currentChart.version,
+        }
+      )
+      .then((res: any) => {
+        if (!isSubscribed) {
+          return;
+        }
+        let controllers =
+          currentChart.chart.metadata.name == "job"
+            ? res.data[0]?.status.active
+            : res.data;
+        setControllers(controllers);
+        setIsLoading(false);
+      })
+      .catch((err) => {
+        if (!isSubscribed) {
+          return;
+        }
+        setCurrentError(JSON.stringify(err));
+        setControllers([]);
+        setIsLoading(false);
+      });
+    return () => {
+      isSubscribed = false;
+    };
+  }, [currentProject, currentCluster, setCurrentError, currentChart]);
+
+  const renderTabs = () => {
+    return controllers.map((c, i) => {
+      return (
+        <ControllerTab
+          // handle CronJob case
+          key={c.metadata?.uid || c.uid}
+          selectedPod={selectedPod}
+          selectPod={(pod: any, userSelected) => {
+            setSelectedPod(pod);
+
+            if (userSelected) {
+              onSelectPod(pod);
+            }
+          }}
+          selectors={selectors ? [selectors[i]] : null}
+          controller={c}
+          isLast={i === controllers?.length - 1}
+          isFirst={i === 0}
+          setPodError={(x: string) => setPodError(x)}
+          onUpdate={onUpdate}
+        />
+      );
+    });
+  };
+
+  const renderStatusSection = () => {
+    if (isLoading) {
+      return (
+        <NoControllers>
+          <Loading />
+        </NoControllers>
+      );
+    }
+    if (controllers?.length > 0) {
+      return <TabWrapper>{renderTabs()}</TabWrapper>;
+    }
+
+    return (
+      <NoControllers>
+        <i className="material-icons">category</i>
+        No objects to display. This might happen while your app is still
+        deploying.
+      </NoControllers>
+    );
+  };
+
+  return <StyledStatusSection>{renderStatusSection()}</StyledStatusSection>;
+};
+
+export default PodDropdown;
+
+const TabWrapper = styled.div`
+  width: 100%;
+  min-height: 50px;
+`;
+
+const StyledStatusSection = styled.div`
+  padding: 0px;
+  user-select: text;
+  overflow: hidden;
+  width: 100%;
+  font-size: 13px;
+`;
+
+const NoControllers = styled.div`
+  position: relative;
+  width: 100%;
+  display: flex;
+  min-height: 50px;
+  justify-content: center;
+  align-items: center;
+  color: #ffffff44;
+  font-size: 14px;
+
+  > i {
+    font-size: 18px;
+    margin-right: 12px;
+  }
+`;

+ 223 - 0
dashboard/src/main/home/cluster-dashboard/expanded-chart/deploy-status-section/PodRow.tsx

@@ -0,0 +1,223 @@
+import React, { useState } from "react";
+import styled from "styled-components";
+import { ControllerTabPodType } from "./ControllerTab";
+
+type PodRowProps = {
+  pod: ControllerTabPodType;
+  isSelected: boolean;
+  isLastItem: boolean;
+  onTabClick: any;
+  onDeleteClick: any;
+  podStatus: string;
+};
+
+const PodRow: React.FunctionComponent<PodRowProps> = ({
+  pod,
+  isSelected,
+  onTabClick,
+  onDeleteClick,
+  isLastItem,
+  podStatus,
+}) => {
+  const [showTooltip, setShowTooltip] = useState(false);
+
+  return (
+    <Tab key={pod?.name} onClick={onTabClick}>
+      <Gutter>
+        <Rail />
+        <Circle />
+        <Rail lastTab={isLastItem} />
+      </Gutter>
+      <Name
+      // onMouseOver={() => {
+      //   setShowTooltip(true);
+      // }}
+      // onMouseOut={() => {
+      //   setShowTooltip(false);
+      // }}
+      >
+        {pod?.name}
+      </Name>
+      {showTooltip && (
+        <Tooltip>
+          {pod?.name}
+          <Grey>Restart count: {pod.restartCount}</Grey>
+          <Grey>Created on: {pod.podAge}</Grey>
+          {podStatus === "failed" ? (
+            <FailedStatusContainer>
+              <Grey>
+                Failure Reason: {pod?.containerStatus?.state?.waiting?.reason}
+              </Grey>
+              <Grey>{pod?.containerStatus?.state?.waiting?.message}</Grey>
+            </FailedStatusContainer>
+          ) : null}
+        </Tooltip>
+      )}
+
+      <Status>
+        {podStatus}
+        <StatusColor status={podStatus} />
+        {/* {podStatus === "failed" && (
+          <CloseIcon
+            className="material-icons-outlined"
+            onClick={onDeleteClick}
+          >
+            close
+          </CloseIcon>
+        )} */}
+      </Status>
+    </Tab>
+  );
+};
+
+export default PodRow;
+
+const Grey = styled.div`
+  margin-top: 5px;
+  color: #aaaabb;
+`;
+
+const FailedStatusContainer = styled.div`
+  width: 100%;
+  border: 1px solid hsl(0deg, 100%, 30%);
+  padding: 5px;
+  margin-block: 5px;
+`;
+
+const Tooltip = styled.div`
+  position: absolute;
+  left: 35px;
+  word-wrap: break-word;
+  top: 38px;
+  min-height: 18px;
+  max-width: calc(100% - 75px);
+  padding: 5px 7px;
+  background: #272731;
+  z-index: 999;
+  display: flex;
+  flex-direction: column;
+  justify-content: center;
+  flex: 1;
+  color: white;
+  text-transform: none;
+  font-size: 12px;
+  font-family: "Work Sans", sans-serif;
+  outline: 1px solid #ffffff55;
+  opacity: 0;
+  animation: faded-in 0.2s 0.15s;
+  animation-fill-mode: forwards;
+  @keyframes faded-in {
+    from {
+      opacity: 0;
+    }
+    to {
+      opacity: 1;
+    }
+  }
+`;
+
+const CloseIcon = styled.i`
+  font-size: 14px;
+  display: flex;
+  font-weight: bold;
+  align-items: center;
+  justify-content: center;
+  border-radius: 5px;
+  background: #ffffff22;
+  width: 18px;
+  height: 18px;
+  margin-right: -6px;
+  margin-left: 10px;
+  cursor: pointer;
+  :hover {
+    background: #ffffff44;
+  }
+`;
+
+const Tab = styled.div`
+  width: 100%;
+  height: 50px;
+  position: relative;
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  font-size: 13px;
+  padding: 20px 18px 20px 42px;
+  text-shadow: 0px 0px 8px none;
+  overflow: visible;
+  cursor: pointer;
+`;
+
+const Rail = styled.div`
+  width: 2px;
+  background: ${(props: { lastTab?: boolean }) =>
+    props.lastTab ? "" : "#52545D"};
+  height: 50%;
+`;
+
+const Circle = styled.div`
+  min-width: 10px;
+  min-height: 2px;
+  margin-bottom: -2px;
+  margin-left: 8px;
+  background: #52545d;
+`;
+
+const Gutter = styled.div`
+  position: absolute;
+  top: 0px;
+  left: 10px;
+  height: 100%;
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  justify-content: center;
+  overflow: visible;
+`;
+
+const Status = styled.div`
+  display: flex;
+  font-size: 12px;
+  text-transform: capitalize;
+  margin-left: 10px;
+  justify-content: flex-end;
+  align-items: center;
+  font-family: "Work Sans", sans-serif;
+  color: #aaaabb;
+  animation: fadeIn 0.5s;
+  @keyframes fadeIn {
+    from {
+      opacity: 0;
+    }
+    to {
+      opacity: 1;
+    }
+  }
+`;
+
+const StatusColor = styled.div`
+  margin-left: 12px;
+  mnargin-right: -1px;
+  width: 8px;
+  min-width: 7px;
+  height: 8px;
+  background: ${(props: { status: string }) =>
+    props.status === "running"
+      ? "#4797ff"
+      : props.status === "failed"
+      ? "#ed5f85"
+      : props.status === "completed"
+      ? "#00d12a"
+      : "#f5cb42"};
+  border-radius: 20px;
+`;
+
+const Name = styled.div`
+  overflow: hidden;
+  text-overflow: ellipsis;
+  line-height: 1.5em;
+  display: -webkit-box;
+  overflow-wrap: anywhere;
+  -webkit-box-orient: vertical;
+  -webkit-line-clamp: 2;
+`;

+ 316 - 0
dashboard/src/main/home/cluster-dashboard/expanded-chart/deploy-status-section/ResourceTab.tsx

@@ -0,0 +1,316 @@
+import React, { Component } from "react";
+import styled from "styled-components";
+
+import { kindToIcon } from "shared/rosettaStone";
+
+type PropsType = {
+  label: string;
+  name: string;
+  handleClick?: () => void;
+  selected?: boolean;
+  isLast?: boolean;
+  roundAllCorners?: boolean;
+  status?: {
+    label: string;
+    available?: number;
+    total?: number;
+  } | null;
+  expanded?: boolean;
+};
+
+type StateType = {
+  expanded: boolean;
+  showTooltip: boolean;
+};
+
+export default class ResourceTab extends Component<PropsType, StateType> {
+  state = {
+    expanded: this.props.expanded || false,
+    showTooltip: false,
+  };
+
+  renderDropdownIcon = () => {
+    if (this.props.children) {
+      return (
+        <DropdownIcon expanded={this.state.expanded}>
+          <i className="material-icons">arrow_right</i>
+        </DropdownIcon>
+      );
+    }
+  };
+
+  renderIcon = (kind: string) => {
+    let icon = "tonality";
+    if (Object.keys(kindToIcon).includes(kind)) {
+      icon = kindToIcon[kind];
+    }
+
+    return (
+      <IconWrapper>
+        <i className="material-icons">{icon}</i>
+      </IconWrapper>
+    );
+  };
+
+  renderTooltip = (x: string): JSX.Element | undefined => {
+    if (this.state.showTooltip) {
+      return <Tooltip>{x}</Tooltip>;
+    }
+  };
+
+  getStatusText = () => {
+    let { status } = this.props;
+    if (status.available && status.total) {
+      return `${status.available}/${status.total}`;
+    } else if (status.label) {
+      return status.label;
+    }
+  };
+
+  renderStatus = () => {
+    let { status } = this.props;
+    if (status) {
+      return (
+        <Status>
+          {this.getStatusText()}
+
+          <StatusColor status={status.label} />
+        </Status>
+      );
+    }
+  };
+
+  renderExpanded = () => {
+    if (this.props.children && this.state.expanded) {
+      return <ExpandWrapper>{this.props.children}</ExpandWrapper>;
+    }
+  };
+
+  render() {
+    let {
+      label,
+      name,
+      children,
+      isLast,
+      handleClick,
+      selected,
+      status,
+      roundAllCorners,
+    } = this.props;
+    return (
+      <StyledResourceTab
+        isLast={isLast}
+        onClick={() => handleClick && handleClick()}
+        roundAllCorners={roundAllCorners}
+      >
+        <ResourceHeader
+          hasChildren={children && true}
+          expanded={this.state.expanded || selected}
+          onClick={() => {
+            if (children) {
+              this.setState({ expanded: !this.state.expanded });
+            }
+          }}
+        >
+          <Info>
+            {this.renderDropdownIcon()}
+            <Metadata hasStatus={status && true}>
+              {this.renderIcon(label)}
+              {label}
+              <ResourceName
+                showKindLabels={true}
+                onMouseOver={() => {
+                  this.setState({ showTooltip: true });
+                }}
+                onMouseOut={() => {
+                  this.setState({ showTooltip: false });
+                }}
+              >
+                {name}
+              </ResourceName>
+              {this.renderTooltip(name)}
+            </Metadata>
+          </Info>
+          {this.renderStatus()}
+        </ResourceHeader>
+        {this.renderExpanded()}
+      </StyledResourceTab>
+    );
+  }
+}
+
+const StyledResourceTab = styled.div`
+  width: 100%;
+  font-size: 13px;
+  border-bottom-left-radius: ${(props: {
+    isLast: boolean;
+    roundAllCorners: boolean;
+  }) => (props.isLast ? "10px" : "")};
+  animation: fadeIn 0.2s;
+  animation-timing-function: ease-out;
+  animation-fill-mode: forwards;
+  @keyframes fadeIn {
+    from {
+      opacity: 0;
+    }
+    to {
+      opacity: 1;
+    }
+  }
+`;
+
+const Tooltip = styled.div`
+  position: absolute;
+  right: 0px;
+  top: 25px;
+  white-space: nowrap;
+  height: 18px;
+  padding: 2px 5px;
+  background: #383842dd;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  flex: 1;
+  color: white;
+  text-transform: none;
+  font-size: 12px;
+  font-family: "Work Sans", sans-serif;
+  outline: 1px solid #ffffff55;
+  opacity: 0;
+  animation: faded-in 0.2s 0.15s;
+  animation-fill-mode: forwards;
+  @keyframes faded-in {
+    from {
+      opacity: 0;
+    }
+    to {
+      opacity: 1;
+    }
+  }
+`;
+
+const ExpandWrapper = styled.div``;
+
+const ResourceHeader = styled.div`
+  width: 100%;
+  height: 50px;
+  display: flex;
+  font-size: 13px;
+  align-items: center;
+  justify-content: space-between;
+  color: #ffffff66;
+  user-select: none;
+  padding: 8px 18px;
+  padding-left: ${(props: { expanded: boolean; hasChildren: boolean }) =>
+    props.hasChildren ? "10px" : "22px"};
+  cursor: pointer;
+  :hover {
+    background: #ffffff18;
+
+    > i {
+      background: #ffffff22;
+    }
+  }
+`;
+
+const Info = styled.div`
+  display: flex;
+  flex-direction: row;
+  align-items: center;
+  width: 80%;
+  height: 100%;
+`;
+
+const Metadata = styled.div`
+  display: flex;
+  align-items: center;
+  position: relative;
+  max-width: ${(props: { hasStatus: boolean }) =>
+    props.hasStatus ? "calc(100% - 20px)" : "100%"};
+`;
+
+const Status = styled.div`
+  display: flex;
+  width; 20%;
+  font-size: 12px;
+  text-transform: capitalize;
+  justify-content: flex-end;
+  align-items: center;
+  font-family: 'Work Sans', sans-serif;
+  color: #aaaabb;
+  animation: fadeIn 0.5s;
+  @keyframes fadeIn {
+    from { opacity: 0 }
+    to { opacity: 1 }
+  }
+`;
+
+const StatusColor = styled.div`
+  margin-left: 12px;
+  width: 8px;
+  min-width: 8px;
+  height: 8px;
+  background: ${(props: { status: string }) =>
+    props.status === "running" ||
+    props.status === "Ready" ||
+    props.status === "Completed"
+      ? "#4797ff"
+      : props.status === "failed" || props.status === "FailedValidation"
+      ? "#ed5f85"
+      : props.status === "completed"
+      ? "#00d12a"
+      : "#f5cb42"};
+  border-radius: 20px;
+`;
+
+const ResourceName = styled.div`
+  color: #ffffff;
+  margin-right: 15px;
+  margin-left: ${(props: { showKindLabels: boolean }) =>
+    props.showKindLabels ? "10px" : ""};
+  text-transform: none;
+  white-space: nowrap;
+  overflow: hidden;
+  text-overflow: ellipsis;
+`;
+
+const IconWrapper = styled.div`
+  width: 25px;
+  height: 25px;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+
+  > i {
+    font-size: 15px;
+    color: #ffffff;
+    margin-right: 14px;
+  }
+`;
+
+const DropdownIcon = styled.div`
+  > i {
+    margin-top: 2px;
+    margin-right: 11px;
+    font-size: 20px;
+    color: #ffffff66;
+    cursor: pointer;
+    border-radius: 20px;
+    background: ${(props: { expanded: boolean }) =>
+      props.expanded ? "#ffffff18" : ""};
+    transform: ${(props: { expanded: boolean }) =>
+      props.expanded ? "rotate(180deg)" : ""};
+    animation: ${(props: { expanded: boolean }) =>
+      props.expanded ? "quarterTurn 0.3s" : ""};
+    animation-fill-mode: forwards;
+
+    @keyframes quarterTurn {
+      from {
+        transform: rotate(0deg);
+      }
+      to {
+        transform: rotate(90deg);
+      }
+    }
+  }
+`;

+ 53 - 0
dashboard/src/main/home/cluster-dashboard/expanded-chart/deploy-status-section/util.ts

@@ -0,0 +1,53 @@
+export const getPodStatus = (status: any) => {
+  if (status?.phase === "Pending" && status?.containerStatuses !== undefined) {
+    return status.containerStatuses[0].state?.waiting?.reason || "Pending";
+  } else if (status?.phase === "Pending") {
+    return "Pending";
+  }
+
+  if (status?.phase === "Failed") {
+    return "failed";
+  }
+
+  if (status?.phase === "Running") {
+    let collatedStatus = "running";
+
+    status?.containerStatuses?.forEach((s: any) => {
+      if (s.state?.waiting) {
+        collatedStatus =
+          s.state?.waiting?.reason === "CrashLoopBackOff"
+            ? "failed"
+            : "waiting";
+      } else if (
+        s.state?.terminated &&
+        (s.state.terminated?.exitCode !== 0 ||
+          s.state.terminated?.reason !== "Completed")
+      ) {
+        collatedStatus = "failed";
+      }
+    });
+    return collatedStatus;
+  }
+};
+
+export const getAvailability = (kind: string, c: any) => {
+  switch (kind?.toLowerCase()) {
+    case "deployment":
+    case "replicaset":
+      return [
+        c.status?.availableReplicas ||
+          c.status?.replicas - c.status?.unavailableReplicas ||
+          0,
+        c.status?.replicas || 0,
+      ];
+    case "statefulset":
+      return [c.status?.readyReplicas || 0, c.status?.replicas || 0];
+    case "daemonset":
+      return [
+        c.status?.numberAvailable || 0,
+        c.status?.desiredNumberScheduled || 0,
+      ];
+    case "job":
+      return [1, 1];
+  }
+};

+ 487 - 0
dashboard/src/main/home/cluster-dashboard/expanded-chart/events/EventList.tsx

@@ -0,0 +1,487 @@
+import React, { useState, useEffect, useContext } from "react";
+import { CellProps } from "react-table";
+
+import styled from "styled-components";
+import EventTable from "./EventTable";
+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, 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";
+import { Context } from "shared/Context";
+import { InitLogData } from "../logs-section/LogsSection";
+import { setServers } from "dns";
+
+type Props = {
+  filters: any;
+  setLogData?: (logData: InitLogData) => void;
+};
+
+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;
+    }
+
+    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>",
+        {
+          incident_id: expandedEvent.id,
+        },
+        {
+          project_id: currentProject.id,
+          cluster_id: currentCluster.id,
+        }
+      )
+      .then((res) => {
+        setExpandedIncidentEvents(res.data.events);
+      });
+  }, [expandedEvent]);
+
+  const redirectToLogs = (incident: any) => {
+    api
+      .getIncidentEvents(
+        "<token>",
+        {
+          incident_id: incident.id,
+        },
+        {
+          project_id: currentProject.id,
+          cluster_id: currentCluster.id,
+        }
+      )
+      .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(
+    () => [
+      {
+        Header: "Monitors",
+        columns: [
+          {
+            Header: "Description",
+            accessor: "type",
+            width: 500,
+            Cell: ({ row }: CellProps<any>) => {
+              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: "Last Seen",
+            accessor: "timestamp",
+            width: 140,
+            Cell: ({ row }: CellProps<any>) => {
+              return <Flex>{relativeDate(row.original.timestamp)}</Flex>;
+            },
+          },
+          {
+            id: "details",
+            accessor: "",
+            width: 20,
+            Cell: ({ row }: CellProps<any>) => {
+              if (row.original.type == "incident") {
+                return (
+                  <TableButton
+                    onClick={() => {
+                      setExpandedEvent(row.original.data);
+                    }}
+                  >
+                    <Icon src={info} />
+                    Details
+                  </TableButton>
+                );
+              }
+
+              return null;
+            },
+          },
+          {
+            id: "logs",
+            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"
+                  onClick={() => {
+                    redirectToLogs(row.original.data);
+                  }}
+                >
+                  <Icon src={document} />
+                  View logs
+                </TableButton>
+              );
+            },
+          },
+        ],
+      },
+    ],
+    []
+  );
+
+  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>
+          {renderExpandedEventMessage()}
+        </Modal>
+      )}
+      {isLoading ? (
+        <LoadWrapper>
+          <Loading />
+        </LoadWrapper>
+      ) : (
+        <TableWrapper>
+          <EventTable columns={columns} data={events} />
+          <FlexRow>
+            <Flex>
+              <Button
+                onClick={() => {
+                  setIsLoading(true);
+                  setRefresh(true);
+                }}
+              >
+                <i className="material-icons">autorenew</i>
+                Refresh
+              </Button>
+            </Flex>
+          </FlexRow>
+        </TableWrapper>
+      )}
+    </>
+  );
+};
+
+export default EventList;
+
+const Message = styled.div`
+  padding: 20px;
+  background: #26292e;
+  border-radius: 5px;
+  line-height: 1.5em;
+  border: 1px solid #aaaabb33;
+  font-size: 13px;
+  display: flex;
+  align-items: center;
+  > img {
+    width: 13px;
+    margin-right: 20px;
+  }
+`;
+
+const Capitalize = styled.div`
+  text-transform: capitalize;
+`;
+
+const Bold = styled.div`
+  font-weight: 500;
+  margin-right: 5px;
+`;
+
+const InfoTab = styled.div`
+  display: flex;
+  align-items: center;
+  opacity: 50%;
+  font-size: 13px;
+  margin-right: 15px;
+  justify-content: center;
+
+  > img {
+    width: 13px;
+    margin-right: 7px;
+  }
+`;
+
+const InfoRow = styled.div`
+  display: flex;
+  align-items: center;
+  justify-content: flex-start;
+  margin-bottom: 25px;
+`;
+
+const Text = styled.div`
+  font-weight: 500;
+  font-size: 18px;
+  z-index: 999;
+`;
+
+const Icon = styled.img`
+  width: 16px;
+  margin-right: 6px;
+`;
+
+const TableButton = styled.div<{ width?: string }>`
+  border-radius: 5px;
+  height: 30px;
+  color: white;
+  width: ${(props) => props.width || "85px"};
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  background: #ffffff11;
+  border: 1px solid #aaaabb33;
+  cursor: pointer;
+  :hover {
+    border: 1px solid #7a7b80;
+  }
+`;
+
+const ClusterName = styled.div`
+  overflow: hidden;
+  white-space: nowrap;
+  text-overflow: ellipsis;
+  background: blue;
+  width: 100px;
+`;
+
+const Title = styled.div`
+  font-size: 18px;
+  margin-bottom: 10px;
+  color: #ffffff;
+`;
+
+const Placeholder = styled.div`
+  width: 100%;
+  height: 300px;
+  color: #aaaabb55;
+  display: flex;
+  font-size: 14px;
+  padding-right: 50px;
+  align-items: center;
+  justify-content: center;
+`;
+
+const ClusterIcon = styled.img`
+  width: 14px;
+  margin-right: 9px;
+  opacity: 70%;
+`;
+
+const Flex = styled.div`
+  display: flex;
+  align-items: center;
+`;
+
+const AlertIcon = styled.img`
+  width: 20px;
+  margin-right: 15px;
+  margin-left: 0px;
+`;
+
+const NameWrapper = styled.div`
+  display: flex;
+  align-items: center;
+  color: white;
+`;
+
+const LoadWrapper = styled.div`
+  width: 100%;
+  height: 300px;
+`;
+
+const Status = styled.div<{ color: string }>`
+  padding: 5px 7px;
+  background: ${(props) => props.color};
+  font-size: 12px;
+  border-radius: 3px;
+  word-break: keep-all;
+  display: flex;
+  color: white;
+  margin-right: 50px;
+  align-items: center;
+  margin-left: 15px;
+  justify-content: center;
+  height: 20px;
+`;
+
+const TableWrapper = styled.div`
+  overflow-x: auto;
+  animation: fadeIn 0.3s;
+  animation-timing-function: ease-out;
+  animation-fill-mode: forwards;
+`;
+
+const StyledMonitorList = styled.div`
+  height: 200px;
+  width: 100%;
+  font-size: 13px;
+  background: #ffffff11;
+  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;
+`;

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

@@ -0,0 +1,99 @@
+import Placeholder from "components/Placeholder";
+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,
+}) => {
+  if (!data || data.length == 0) {
+    return <Placeholder>No events found.</Placeholder>;
+  }
+
+  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;

+ 68 - 149
dashboard/src/main/home/cluster-dashboard/expanded-chart/events/EventsTab.tsx

@@ -1,97 +1,81 @@
-import React, { useEffect, useMemo, useState } from "react";
+import React, { useEffect, useContext, useState } from "react";
+import api from "shared/api";
 import styled from "styled-components";
-import EventCard from "components/events/EventCard";
+import EventList from "./EventList";
 import Loading from "components/Loading";
 import InfiniteScroll from "react-infinite-scroll-component";
+import { Context } from "shared/Context";
 import Dropdown from "components/Dropdown";
-import { useKubeEvents } from "components/events/useEvents";
-import { ChartType } from "shared/types";
-import _, { isEmpty, isObject } from "lodash";
-import SubEventsList from "components/events/SubEventsList";
+import { InitLogData } from "../logs-section/LogsSection";
 
-const availableResourceTypes = [
-  { label: "Pods", value: "pod" },
-  { label: "HPA", value: "hpa" },
-];
-
-const EventsTab: React.FC<{
-  controllers: Record<string, Record<string, any>>;
-}> = (props) => {
-  const { controllers } = props;
-  const [resourceType, setResourceType] = useState(availableResourceTypes[0]);
-  const [currentEvent, setCurrentEvent] = useState(null);
-
-  const [selectedControllerKey, setSelectedControllerKey] = useState(null);
-
-  const [hasControllers, setHasControllers] = useState(null);
-
-  const controllerOptions = useMemo(() => {
-    if (typeof controllers !== "object") {
-      return [];
-    }
-
-    return Object.entries(controllers).map(([key, value]) => ({
-      label: value?.metadata?.name,
-      value: key,
-    }));
-  }, [controllers]);
-
-  const currentControllerOption = useMemo(() => {
-    return (
-      controllerOptions?.find((c) => c.value === selectedControllerKey) ||
-      controllerOptions[0]
-    );
-  }, [selectedControllerKey, controllerOptions]);
-
-  const selectedController = controllers[currentControllerOption?.value];
+type Props = {
+  currentChart: any;
+  setLogData?: (logData: InitLogData) => void;
+  overridingJobName?: string;
+};
 
-  const {
-    isLoading,
-    hasPorterAgent,
-    triggerInstall,
-    kubeEvents,
-    loadMoreEvents,
-    hasMore,
-  } = useKubeEvents({
-    resourceType: resourceType.value as any,
-    ownerName: selectedController?.metadata?.name,
-    ownerType: selectedController?.kind,
-    ownerNamespace: selectedController?.metadata?.namespace,
-    shouldWaitForOwner: true,
-  });
+const EventsTab: React.FC<Props> = ({
+  currentChart,
+  setLogData,
+  overridingJobName,
+}) => {
+  const [hasPorterAgent, setHasPorterAgent] = useState(true);
+  const { currentProject, currentCluster } = useContext(Context);
+  const [isLoading, setIsLoading] = useState(true);
 
   useEffect(() => {
-    let timer: NodeJS.Timeout = null;
-
-    const checkControllers = (counter = 0) => {
-      if (timer !== null) {
-        clearTimeout(timer);
-      }
-
-      if (isEmpty(controllers) && counter === 5) {
-        clearTimeout(timer);
-        setHasControllers(false);
-      } else {
-        if (isEmpty(controllers)) {
-          timer = setTimeout(() => {
-            checkControllers(counter + 1);
-          }, 2000);
-        } else {
-          setHasControllers(true);
+    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);
         }
-      }
-    };
-
-    checkControllers();
+      });
+  }, []);
+
+  const installAgent = async () => {
+    const project_id = currentProject?.id;
+    const cluster_id = currentCluster?.id;
+
+    api
+      .installPorterAgent("<token>", {}, { project_id, cluster_id })
+      .then(() => {
+        setHasPorterAgent(true);
+      })
+      .catch((err) => {
+        console.log(err);
+      });
+  };
+
+  const triggerInstall = () => {
+    installAgent();
+  };
+
+  const getFilters = () => {
+    if (overridingJobName) {
+      return {
+        release_name: currentChart.name,
+        release_namespace: currentChart.namespace,
+        job_name: overridingJobName,
+      };
+    }
 
-    return () => {
-      if (timer !== null) {
-        clearTimeout(timer);
-      }
+    return {
+      release_name: currentChart.name,
+      release_namespace: currentChart.namespace,
     };
-  }, [controllers]);
+  };
 
-  if (isLoading && hasControllers === null) {
+  if (isLoading) {
     return (
       <Placeholder>
         <Loading />
@@ -99,22 +83,12 @@ const EventsTab: React.FC<{
     );
   }
 
-  if (!hasControllers) {
-    return (
-      <Placeholder>
-        <i className="material-icons">search</i>
-        We coulnd't find any controllers for this application.
-      </Placeholder>
-    );
-  }
-
   if (!hasPorterAgent) {
     return (
       <Placeholder>
         <div>
           <Header>We couldn't detect the Porter agent on your cluster</Header>
-          In order to use the events tab, you need to install the Porter agent
-          on your cluster.
+          In order to use the events tab, you need to install the Porter agent.
           <InstallPorterAgentButton onClick={() => triggerInstall()}>
             <i className="material-icons">add</i> Install Porter agent
           </InstallPorterAgentButton>
@@ -123,64 +97,9 @@ const EventsTab: React.FC<{
     );
   }
 
-  if (currentEvent) {
-    return (
-      <SubEventsList
-        event={currentEvent}
-        clearSelectedEvent={() => setCurrentEvent(null)}
-      />
-    );
-  }
-
   return (
     <EventsPageWrapper>
-      {kubeEvents.length > 0 ? (
-        <>
-          <ControlRow>
-            {/*
-              <Dropdown
-                selectedOption={resourceType}
-                options={availableResourceTypes}
-                onSelect={(o) => setResourceType({ ...o, value: o.value as string })}
-              />
-              */}
-            <Label>Controller -</Label>
-            <Dropdown
-              selectedOption={currentControllerOption}
-              options={controllerOptions}
-              onSelect={(o) => setSelectedControllerKey(o?.value)}
-            />
-          </ControlRow>
-
-          <InfiniteScroll
-            dataLength={kubeEvents.length}
-            next={loadMoreEvents}
-            hasMore={hasMore}
-            loader={<h4>Loading...</h4>}
-            scrollableTarget="HomeViewWrapper"
-          >
-            <EventsGrid>
-              {kubeEvents.map((event, i) => {
-                return (
-                  <React.Fragment key={i}>
-                    <EventCard
-                      event={event as any}
-                      selectEvent={() => {
-                        setCurrentEvent(event);
-                      }}
-                    />
-                  </React.Fragment>
-                );
-              })}
-            </EventsGrid>
-          </InfiniteScroll>
-        </>
-      ) : (
-        <Placeholder>
-          <i className="material-icons">search</i>
-          No matching events were found.
-        </Placeholder>
-      )}
+      <EventList setLogData={setLogData} filters={getFilters()} />
     </EventsPageWrapper>
   );
 };

+ 155 - 0
dashboard/src/main/home/cluster-dashboard/expanded-chart/events/styles.ts

@@ -0,0 +1,155 @@
+import styled, { css } from "styled-components";
+
+const textFontStack = css`
+  font-family: "Work Sans", Arial, sans-serif;
+`;
+
+export const theme = {
+  bg: {
+    default: "#FFFFFF",
+    reverse: "#16171A",
+    wash: "#FAFAFA",
+    divider: "#F6F7F8",
+    border: "#EBECED",
+    inactive: "#DFE7EF",
+    shadeone: "#26292E",
+    shadetwo: "#26292E",
+  },
+  line: {
+    default: "1px solid #aaaabb33",
+  },
+  brand: {
+    default: "#4400CC",
+    alt: "#7B16FF",
+    wash: "#E8E5FF",
+    border: "#DDD9FF",
+    dark: "#2A0080",
+  },
+  generic: {
+    default: "#E6ECF7",
+    alt: "#F6FBFC",
+  },
+  space: {
+    default: "#0062D6",
+    alt: "#1CD2F2",
+    wash: "#E5F0FF",
+    border: "#BDD8FF",
+    dark: "#0F015E",
+  },
+  success: {
+    default: "#00B88B",
+    alt: "#00D5BD",
+    dark: "#00663C",
+    wash: "#D9FFF2",
+    border: "#9FF5D9",
+  },
+  text: {
+    default: "#ffffffaa",
+    secondary: "#384047",
+    alt: "#67717A",
+    placeholder: "#7C8894",
+    reverse: "#FFFFFF",
+  },
+  warn: {
+    default: "#E22F2F",
+    alt: "#E2197A",
+    dark: "#85000C",
+    wash: "#FFEDF6",
+    border: "#FFCCE5",
+  },
+};
+
+export const StyledTable = styled.table`
+  width: 100%;
+  min-width: 500px;
+  border-radius: 5px;
+  overflow: hidden;
+  border: 1px solid #aaaabb33;
+  border-spacing: 0;
+`;
+
+export const StyledTHead = styled.thead`
+  width: 100%;
+  position: sticky;
+
+  > tr {
+    background: ${theme.bg.shadeone};
+    line-height: 2.2em;
+
+    > th {
+      border-bottom: ${theme.line.default};
+    }
+  }
+
+  > tr:first-child {
+    > th:first-child {
+      border-top-left-radius: 6px;
+      display: none;
+    }
+
+    > th:last-child {
+      border-top-right-radius: 6px;
+    }
+  }
+`;
+
+export const StyledTBody = styled.tbody`
+  > tr {
+    background: ${theme.bg.shadetwo};
+    height: 80px;
+    line-height: 1.2em;
+
+    > td {
+      border-bottom: ${theme.line.default};
+    }
+
+    > td:first-child {
+    }
+
+    > td:last-child {
+    }
+  }
+
+  > tr:last-child {
+    > td:first-child {
+      border-bottom-left-radius: 6px;
+    }
+
+    > td:last-child {
+      border-bottom-right-radius: 6px;
+    }
+
+    > td {
+      border-bottom: none;
+    }
+  }
+`;
+
+export const StyledTd = styled.td`
+  ${textFontStack}
+  font-size: 13px;
+  color: ${theme.text.default};
+  :first-child {
+    padding-left: 20px;
+  }
+
+  :last-child {
+  }
+
+  user-select: text;
+`;
+
+export const StyledTh = styled.th`
+  ${textFontStack}
+
+  text-align: left;
+  font-size: 13px;
+  font-weight: 400;
+  color: #ffffffaa;
+  :first-child {
+    padding-left: 20px;
+  }
+  :last-child {
+    padding-right: 10px;
+  }
+`;

+ 0 - 216
dashboard/src/main/home/cluster-dashboard/expanded-chart/incidents/IncidentsTab.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;
+`;

+ 4 - 4
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>
@@ -560,7 +560,7 @@ const Subtitle = styled.div`
 `;
 
 const JobLogsWrapper = styled.div`
-  height: 250px;
+  max-height: 500px;
   width: 100%;
   background-color: black;
   overflow-y: auto;

+ 685 - 0
dashboard/src/main/home/cluster-dashboard/expanded-chart/logs-section/LogsSection.tsx

@@ -0,0 +1,685 @@
+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";
+import Banner from "components/Banner";
+
+export type InitLogData = Partial<{
+  podName: string;
+  timestamp: string;
+  revision: string;
+}>;
+
+type Props = {
+  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 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 [notification, setNotification] = useState<string>();
+
+  const notify = (message: string) => {
+    setNotification(message);
+
+    setTimeout(() => {
+      setNotification(undefined);
+    }, 3000);
+  };
+
+  const { loading, logs, refresh, moveCursor, paginationInfo } = useLogs(
+    podFilter,
+    currentChart.namespace,
+    enteredSearchText,
+    notify,
+    currentChart,
+    selectedDate
+  );
+
+  useEffect(() => {
+    if (overridingPodName) {
+      return;
+    }
+
+    api
+      .getLogPodValues(
+        "<TOKEN>",
+        {
+          namespace: currentChart?.namespace,
+          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]);
+        }
+      });
+  }, [initData]);
+
+  useEffect(() => {
+    if (!loading && scrollToBottomRef.current && scrollToBottomEnabled) {
+      scrollToBottomRef.current.scrollIntoView({
+        behavior: "smooth",
+        block: "end",
+      });
+    }
+  }, [loading, logs, scrollToBottomRef, scrollToBottomEnabled]);
+
+  useEffect(() => {
+    if (initData.podName) {
+      setPodFilter(initData.podName);
+    }
+
+    if (initData.timestamp) {
+      setSelectedDate(dayjs(initData.timestamp).toDate());
+    }
+  }, [initData]);
+
+  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>
+          <LogOuter key={[log.lineNumber, i].join(".")}>
+            {log.line?.map((ansi, j) => {
+              if (ansi.clearLine) {
+                return null;
+              }
+
+              return (
+                <LogInnerSpan
+                  key={[log.lineNumber, i, j].join(".")}
+                  ansi={ansi}
+                >
+                  {ansi.content.replace(/ /g, "\u00a0")}
+                </LogInnerSpan>
+              );
+            })}
+          </LogOuter>
+        </Log>
+      );
+    });
+  };
+
+  const onLoadPrevious = useCallback(() => {
+    if (!selectedDate) {
+      setSelectedDate(dayjs(logs[0].timestamp).toDate());
+      return;
+    }
+
+    moveCursor(Direction.backward);
+  }, [logs, selectedDate]);
+
+  const renderContents = () => {
+    return (
+      <>
+        <FlexRow isFullscreen={isFullscreen}>
+          <Flex>
+            <SearchRowWrapper>
+              <SearchBarWrapper>
+                <i className="material-icons">search</i>
+                <SearchInput
+                  value={searchText}
+                  onChange={(e: any) => {
+                    setSearchText(e.target.value);
+                  }}
+                  onKeyPress={(event) => {
+                    if (event.key === "Enter") {
+                      setEnteredSearchText(escapeRegExp(searchText));
+                    }
+                  }}
+                  placeholder="Search logs..."
+                />
+              </SearchBarWrapper>
+            </SearchRowWrapper>
+            <QueryModeSelectionToggle
+              selectedDate={selectedDate}
+              setSelectedDate={setSelectedDate}
+            />
+            <RadioFilter
+              icon={filterOutline}
+              selected={podFilter}
+              setSelected={setPodFilter}
+              options={podFilterOpts?.map((name) => {
+                return {
+                  value: name,
+                  label: name,
+                };
+              })}
+              name="Filter logs"
+            />
+          </Flex>
+          <Flex>
+            <Button onClick={() => setScrollToBottomEnabled((s) => !s)}>
+              <Checkbox checked={scrollToBottomEnabled}>
+                <i className="material-icons">done</i>
+              </Checkbox>
+              Scroll to bottom
+            </Button>
+            <Spacer />
+            <Button onClick={() => refresh()}>
+              <i className="material-icons">autorenew</i>
+              Refresh
+            </Button>
+            {!isFullscreen && (
+              <>
+                <Spacer />
+                <Icon onClick={() => setIsFullscreen(true)}>
+                  <i className="material-icons">open_in_full</i>
+                </Icon>
+              </>
+            )}
+          </Flex>
+        </FlexRow>
+        <StyledLogsSection isFullscreen={isFullscreen}>
+          {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> */}
+              <LoadMoreButton
+                active={selectedDate && logs.length !== 0}
+                role="button"
+                onClick={() => moveCursor(Direction.forward)}
+              >
+                Load more
+              </LoadMoreButton>
+            </>
+          )}
+          <div ref={scrollToBottomRef} />
+          <NotificationWrapper active={!!notification}>
+            <Banner>{notification}</Banner>
+          </NotificationWrapper>
+        </StyledLogsSection>
+      </>
+    );
+  };
+
+  return (
+    <>
+      {isFullscreen ? (
+        <Fullscreen>
+          <AbsoluteTitle>
+            <BackButton onClick={() => setIsFullscreen(false)}>
+              <i className="material-icons">navigate_before</i>
+            </BackButton>
+            Logs ({currentChart.name})
+          </AbsoluteTitle>
+          {renderContents()}
+        </Fullscreen>
+      ) : (
+        <>{renderContents()}</>
+      )}
+    </>
+  );
+};
+
+export default LogsSection;
+
+const BackButton = styled.div`
+  display: flex;
+  width: 30px;
+  z-index: 2;
+  cursor: pointer;
+  height: 30px;
+  align-items: center;
+  margin-right: 15px;
+  justify-content: center;
+  cursor: pointer;
+  border: 1px solid #ffffff55;
+  border-radius: 100px;
+  background: #ffffff11;
+
+  > i {
+    font-size: 18px;
+  }
+
+  :hover {
+    background: #ffffff22;
+    > img {
+      opacity: 1;
+    }
+  }
+`;
+
+const AbsoluteTitle = styled.div`
+  position: absolute;
+  top: 0px;
+  left: 0px;
+  width: 100%;
+  height: 60px;
+  display: flex;
+  align-items: center;
+  padding-left: 20px;
+  font-size: 18px;
+  font-weight: 500;
+  user-select: text;
+`;
+
+const Fullscreen = styled.div`
+  position: absolute;
+  top: 0;
+  left: 0;
+  width: 100%;
+  height: 100%;
+  padding-top: 60px;
+`;
+
+const Icon = styled.div`
+  background: #26292e;
+  border-radius: 5px;
+  height: 30px;
+  width: 30px;
+  display: flex;
+  cursor: pointer;
+  align-items: center;
+  justify-content: center;
+  > i {
+    font-size: 14px;
+  }
+  border: 1px solid #494b4f;
+  :hover {
+    border: 1px solid #7a7b80;
+  }
+`;
+
+const Checkbox = styled.div<{ checked: boolean }>`
+  width: 16px;
+  height: 16px;
+  border: 1px solid #ffffff55;
+  margin: 1px 10px 0px 1px;
+  border-radius: 3px;
+  background: ${(props) => (props.checked ? "#ffffff22" : "#ffffff11")};
+  display: flex;
+  align-items: center;
+  justify-content: center;
+
+  > i {
+    font-size: 12px;
+    padding-left: 0px;
+    display: ${(props) => (props.checked ? "" : "none")};
+  }
+`;
+
+const Spacer = styled.div<{ width?: string }>`
+  height: 100%;
+  width: ${(props) => props.width || "10px"};
+`;
+
+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 Flex = styled.div`
+  display: flex;
+  align-items: center;
+  border-bottom: 25px solid transparent;
+`;
+
+const Message = styled.div`
+  display: flex;
+  height: 100%;
+  width: calc(100% - 150px);
+  align-items: center;
+  justify-content: center;
+  margin-left: 75px;
+  text-align: center;
+  color: #ffffff44;
+  font-size: 13px;
+`;
+
+const Highlight = styled.div`
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  margin-left: 8px;
+  color: #8590ff;
+  cursor: pointer;
+
+  > i {
+    font-size: 16px;
+    margin-right: 3px;
+  }
+`;
+
+const FlexRow = styled.div<{ isFullscreen?: boolean }>`
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  flex-wrap: wrap;
+  margin-top: ${(props) => (props.isFullscreen ? "10px" : "")};
+  padding: ${(props) => (props.isFullscreen ? "0 20px" : "")};
+`;
+
+const SearchBarWrapper = styled.div`
+  display: flex;
+  flex: 1;
+
+  > i {
+    color: #aaaabb;
+    padding-top: 1px;
+    margin-left: 8px;
+    font-size: 16px;
+    margin-right: 8px;
+  }
+`;
+
+const SearchInput = styled.input`
+  outline: none;
+  border: none;
+  font-size: 13px;
+  background: none;
+  width: 100%;
+  color: white;
+  height: 100%;
+`;
+
+const SearchRow = styled.div`
+  display: flex;
+  align-items: center;
+  height: 30px;
+  margin-right: 10px;
+  background: #26292e;
+  border-radius: 5px;
+  border: 1px solid #aaaabb33;
+`;
+
+const SearchRowWrapper = styled(SearchRow)`
+  border-radius: 5px;
+  width: 250px;
+`;
+
+const StyledLogsSection = styled.div<{ isFullscreen: boolean }>`
+  width: 100%;
+  min-height: 400px;
+  height: ${(props) =>
+    props.isFullscreen ? "calc(100vh - 125px)" : "calc(100vh - 460px)"};
+  display: flex;
+  flex-direction: column;
+  position: relative;
+  font-size: 13px;
+  border-radius: ${(props) => (props.isFullscreen ? "" : "8px")};
+  border: ${(props) => (props.isFullscreen ? "" : "1px solid #ffffff33")};
+  border-top: ${(props) => (props.isFullscreen ? "1px solid #ffffff33" : "")};
+  background: #101420;
+  animation: floatIn 0.3s;
+  animation-timing-function: ease-out;
+  animation-fill-mode: forwards;
+  overflow-y: auto;
+  overflow-wrap: break-word;
+  position: relative;
+  @keyframes floatIn {
+    from {
+      opacity: 0;
+      transform: translateY(10px);
+    }
+    to {
+      opacity: 1;
+      transform: translateY(0px);
+    }
+  }
+`;
+
+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 LogOuter = styled.div`
+  display: inline-block;
+  word-wrap: anywhere;
+  flex-grow: 1;
+  font-family: monospace, sans-serif;
+  font-size: 12px;
+`;
+
+const LogInnerSpan = styled.span`
+  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: 2;
+  }
+`;
+
+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: 2;
+  opacity: ${(props) => (props.selected ? "" : "50%")};
+`;
+
+const NotificationWrapper = styled.div<{ active?: boolean }>`
+  position: absolute;
+  bottom: 10px;
+  display: ${(props) => (props.active ? "flex" : "none")};
+  justify-content: center;
+  align-items: center;
+  left: 50%;
+  transform: translateX(-50%);
+  width: fit-content;
+  padding-inline: 10px;
+  background: #101420;
+  animation: bounceIn 0.3s ease-out;
+
+  @keyframes bounceIn {
+    0% {
+      transform: translateZ(-1400px);
+      opacity: 0;
+    }
+    100% {
+      transform: translateZ(0);
+      opacity: 1;
+    }
+  }
+`;

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

@@ -0,0 +1,401 @@
+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,
+  notify: (message: string) => void,
+  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
+      );
+
+      const logsToUpdate = paginationInfo.nextCursor
+        ? newLogs.slice(1)
+        : newLogs;
+
+      // 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(logsToUpdate);
+
+      if (!logsToUpdate.length) {
+        notify("You are already at the latest logs");
+      }
+
+      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,
+  };
+};

+ 14 - 74
dashboard/src/main/home/cluster-dashboard/expanded-chart/metrics/JobMetricsSection.tsx

@@ -38,8 +38,6 @@ const JobMetricsSection: React.FunctionComponent<PropsType> = ({
   jobChart: currentChart,
   jobRun,
 }) => {
-  const [pods, setPods] = useState([]);
-  const [selectedPod, setSelectedPod] = useState("");
   const [controllerOptions, setControllerOptions] = useState([]);
   const [selectedController, setSelectedController] = useState(null);
   const [ingressOptions, setIngressOptions] = useState([]);
@@ -99,48 +97,6 @@ const JobMetricsSection: React.FunctionComponent<PropsType> = ({
       });
   }, [currentChart, currentCluster, currentProject]);
 
-  useEffect(() => {
-    getPods();
-  }, [selectedController]);
-
-  const getPods = () => {
-    const jobName = jobRun?.metadata?.name;
-    const selector = `job-name=${jobName}`;
-
-    setIsLoading((prev) => prev + 1);
-
-    api
-      .getMatchingPods(
-        "<token>",
-        {
-          namespace: selectedController?.metadata?.namespace,
-          selectors: [selector],
-        },
-        {
-          id: currentProject.id,
-          cluster_id: currentCluster.id,
-        }
-      )
-      .then((res) => {
-        const pods = res?.data?.map((pod: any) => {
-          let name = pod?.metadata?.name;
-          return { value: name, label: name };
-        });
-
-        setPods(pods);
-        setSelectedPod(pods[0].value);
-
-        getMetrics();
-      })
-      .catch((err) => {
-        setCurrentError(JSON.stringify(err));
-        return;
-      })
-      .finally(() => {
-        setIsLoading((prev) => prev - 1);
-      });
-  };
-
   const getAutoscalingThreshold = async (
     metricType: "cpu_hpa_threshold" | "memory_hpa_threshold",
     shouldsum: boolean,
@@ -184,9 +140,6 @@ const JobMetricsSection: React.FunctionComponent<PropsType> = ({
   };
 
   const getMetrics = async () => {
-    if (pods?.length == 0) {
-      return;
-    }
     try {
       let namespace = currentChart.namespace;
 
@@ -202,8 +155,6 @@ const JobMetricsSection: React.FunctionComponent<PropsType> = ({
         end = Math.round(new Date().getTime() / 1000);
       }
 
-      let podNames = [selectedPod] as string[];
-
       setIsLoading((prev) => prev + 1);
       setData([]);
 
@@ -211,14 +162,14 @@ const JobMetricsSection: React.FunctionComponent<PropsType> = ({
         "<token>",
         {
           metric: selectedMetric,
-          shouldsum: false,
-          kind: selectedController?.kind,
-          name: selectedController?.metadata.name,
+          shouldsum: true,
+          kind: "job",
+          name: jobRun?.metadata?.name,
           namespace: namespace,
           startrange: start,
           endrange: end,
           resolution: resolutions[selectedRange],
-          pods: podNames,
+          // pods: podNames,
         },
         {
           id: currentProject.id,
@@ -226,13 +177,15 @@ const JobMetricsSection: React.FunctionComponent<PropsType> = ({
         }
       );
 
-      const metrics = new MetricNormalizer(
-        res.data,
-        selectedMetric as AvailableMetrics
-      );
+      if (res.data.length > 0) {
+        const metrics = new MetricNormalizer(
+          res.data,
+          selectedMetric as AvailableMetrics
+        );
 
-      // transform the metrics to expected form
-      setData(metrics.getParsedData());
+        // transform the metrics to expected form
+        setData(metrics.getParsedData());
+      }
     } catch (error) {
       setCurrentError(JSON.stringify(error));
     } finally {
@@ -241,16 +194,10 @@ const JobMetricsSection: React.FunctionComponent<PropsType> = ({
   };
 
   useEffect(() => {
-    if (selectedMetric && selectedRange && selectedPod && selectedController) {
+    if (selectedMetric && selectedRange && selectedController) {
       getMetrics();
     }
-  }, [
-    selectedMetric,
-    selectedRange,
-    selectedPod,
-    selectedController,
-    selectedIngress,
-  ]);
+  }, [selectedMetric, selectedRange, selectedController, selectedIngress]);
 
   const renderMetricsSettings = () => {
     if (showMetricsSettings && true) {
@@ -284,13 +231,6 @@ const JobMetricsSection: React.FunctionComponent<PropsType> = ({
               options={controllerOptions}
               width="100%"
             />
-            <SelectRow
-              label="Target Pod"
-              value={selectedPod}
-              setActiveValue={(x: any) => setSelectedPod(x)}
-              options={pods}
-              width="100%"
-            />
           </DropdownAlt>
         </>
       );

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

@@ -650,7 +650,7 @@ const DropdownAlt = styled(Dropdown)`
 const RangeWrapper = styled.div`
   float: right;
   font-weight: bold;
-  width: 156px;
+  width: 158px;
   margin-top: -8px;
 `;
 

+ 3 - 4
dashboard/src/main/home/cluster-dashboard/expanded-chart/status/Logs.tsx

@@ -2,7 +2,6 @@ import React, { useEffect, useRef, useState } from "react";
 import styled from "styled-components";
 import Anser from "anser";
 import CommandLineIcon from "assets/command-line-icon";
-import ConnectToLogsInstructionModal from "./ConnectToLogsInstructionModal";
 import { SelectedPodType } from "./types";
 import { useLogs } from "./useLogs";
 
@@ -180,7 +179,7 @@ const LogsFC: React.FC<{
             checked={isScrollToBottomEnabled}
             onChange={() => {}}
           />
-          Scroll to Bottom
+          Scroll to bottom
         </Scroll>
         {Array.isArray(previousLogs) && previousLogs.length > 0 && (
           <Scroll
@@ -193,7 +192,7 @@ const LogsFC: React.FC<{
               checked={showPreviousLogs}
               onChange={() => {}}
             />
-            Show previous Logs
+            Show previous logs
           </Scroll>
         )}
         <Refresh onClick={() => refresh()}>
@@ -288,7 +287,7 @@ const Refresh = styled.div`
 const LogTabs = styled.div`
   width: 100%;
   height: 25px;
-  background: #121318;
+  margin-top: -25px;
   display: flex;
   flex-direction: row;
   align-items: center;

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

@@ -3,9 +3,8 @@ import styled from "styled-components";
 
 import api from "shared/api";
 import { Context } from "shared/Context";
-import { ChartType, StorageType } from "shared/types";
+import { ChartType } from "shared/types";
 import Loading from "components/Loading";
-import backArrow from "assets/back_arrow.png";
 
 import Logs from "./Logs";
 import ControllerTab from "./ControllerTab";
@@ -211,11 +210,6 @@ const BackButton = styled.div`
   }
 `;
 
-const BackButtonImg = styled.img`
-  width: 12px;
-  opacity: 0.75;
-`;
-
 const AbsoluteTitle = styled.div`
   position: absolute;
   top: 0px;

+ 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/launch/Launch.tsx

@@ -28,7 +28,7 @@ type TabOption = {
   value: string;
 };
 
-const HIDDEN_CHARTS = ["porter-agent"];
+const HIDDEN_CHARTS = ["porter-agent", "loki"];
 
 type PropsType = RouteComponentProps & {};
 

+ 127 - 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;
@@ -1788,61 +1789,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 }
@@ -2006,6 +1952,122 @@ const getGitlabFolderContent = baseApi<
     `/api/projects/${project_id}/integrations/gitlab/${integration_id}/repos/${repo_owner}/${repo_name}/${branch}/contents`
 );
 
+const getLogPodValues = baseApi<
+  {
+    namespace?: string;
+    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<
@@ -2193,7 +2255,7 @@ export default {
   getGitlabIntegration,
   createAWSIntegration,
   overwriteAWSIntegration,
-  updateClusterName,
+  updateCluster,
   createAzureIntegration,
   createGitlabIntegration,
   createEmailVerification,
@@ -2351,10 +2413,6 @@ export default {
   provisionDatabase,
   getDatabases,
   getPreviousLogsForContainer,
-  getIncidents,
-  getIncidentsByReleaseName,
-  getIncidentById,
-  getIncidentLogsByLogId,
   upgradePorterAgent,
   deletePRDeployment,
   updateBuildConfig,
@@ -2368,7 +2426,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;
+  }
+};

+ 14 - 1
dashboard/webpack.config.js

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

+ 8 - 2
internal/helm/agent.go

@@ -180,6 +180,7 @@ func (a *Agent) UpgradeRelease(
 	conf *UpgradeReleaseConfig,
 	values string,
 	doAuth *oauth2.Config,
+	disablePullSecretsInjection bool,
 ) (*release.Release, error) {
 	valuesYaml, err := chartutil.ReadValues([]byte(values))
 
@@ -189,13 +190,14 @@ func (a *Agent) UpgradeRelease(
 
 	conf.Values = valuesYaml
 
-	return a.UpgradeReleaseByValues(conf, doAuth)
+	return a.UpgradeReleaseByValues(conf, doAuth, disablePullSecretsInjection)
 }
 
 // UpgradeReleaseByValues upgrades a release by unmarshaled yaml values
 func (a *Agent) UpgradeReleaseByValues(
 	conf *UpgradeReleaseConfig,
 	doAuth *oauth2.Config,
+	disablePullSecretsInjection bool,
 ) (*release.Release, error) {
 	// grab the latest release
 	rel, err := a.GetRelease(conf.Name, 0, true)
@@ -220,6 +222,7 @@ func (a *Agent) UpgradeReleaseByValues(
 		rel.Namespace,
 		conf.Registries,
 		doAuth,
+		disablePullSecretsInjection,
 	)
 
 	if err != nil {
@@ -383,6 +386,7 @@ func (a *Agent) InstallChartFromValuesBytes(
 	conf *InstallChartConfig,
 	values []byte,
 	doAuth *oauth2.Config,
+	disablePullSecretsInjection bool,
 ) (*release.Release, error) {
 	valuesYaml, err := chartutil.ReadValues(values)
 
@@ -392,13 +396,14 @@ func (a *Agent) InstallChartFromValuesBytes(
 
 	conf.Values = valuesYaml
 
-	return a.InstallChart(conf, doAuth)
+	return a.InstallChart(conf, doAuth, disablePullSecretsInjection)
 }
 
 // InstallChart installs a new chart
 func (a *Agent) InstallChart(
 	conf *InstallChartConfig,
 	doAuth *oauth2.Config,
+	disablePullSecretsInjection bool,
 ) (*release.Release, error) {
 	cmd := action.NewInstall(a.ActionConfig)
 
@@ -423,6 +428,7 @@ func (a *Agent) InstallChart(
 		conf.Namespace,
 		conf.Registries,
 		doAuth,
+		disablePullSecretsInjection,
 	)
 
 	if err != nil {

+ 2 - 1
internal/helm/postrenderer.go

@@ -32,11 +32,12 @@ func NewPorterPostrenderer(
 	namespace string,
 	regs []*models.Registry,
 	doAuth *oauth2.Config,
+	disablePullSecretsInjection bool,
 ) (postrender.PostRenderer, error) {
 	var dockerSecretsPostrenderer *DockerSecretsPostRenderer
 	var err error
 
-	if cluster != nil && agent != nil && regs != nil && len(regs) > 0 {
+	if !disablePullSecretsInjection && cluster != nil && agent != nil && regs != nil && len(regs) > 0 {
 		dockerSecretsPostrenderer, err = NewDockerSecretsPostRenderer(cluster, repo, agent, namespace, regs, doAuth)
 
 		if err != nil {

Unele fișiere nu au fost afișate deoarece prea multe fișiere au fost modificate în acest diff