Pārlūkot izejas kodu

Merge branch 'master' into nafees/cnb

Mohammed Nafees 3 gadi atpakaļ
vecāks
revīzija
940b3e1301
100 mainītis faili ar 2468 papildinājumiem un 15450 dzēšanām
  1. 2 0
      .github/workflows/production.yaml
  2. 2 0
      .github/workflows/staging.yaml
  3. 4 4
      api/client/api.go
  4. 57 0
      api/server/handlers/cluster/agent_status.go
  5. 61 6
      api/server/handlers/cluster/detect_agent_installed.go
  6. 31 11
      api/server/handlers/cluster/get_incident.go
  7. 64 0
      api/server/handlers/cluster/get_k8s_events.go
  8. 7 7
      api/server/handlers/cluster/get_logs.go
  9. 21 17
      api/server/handlers/cluster/get_logs_pod_values.go
  10. 64 0
      api/server/handlers/cluster/get_logs_revision_values.go
  11. 65 0
      api/server/handlers/cluster/get_porter_events.go
  12. 65 0
      api/server/handlers/cluster/get_porter_job_events.go
  13. 98 9
      api/server/handlers/cluster/install_agent.go
  14. 64 0
      api/server/handlers/cluster/list_incident_events.go
  15. 7 37
      api/server/handlers/cluster/list_incidents.go
  16. 78 20
      api/server/handlers/cluster/notify_new_incident.go
  17. 58 20
      api/server/handlers/cluster/notify_resolved_incident.go
  18. 7 1
      api/server/handlers/cluster/update.go
  19. 1 1
      api/server/handlers/cluster/upgrade_agent.go
  20. 1 0
      api/server/handlers/environment/create.go
  21. 1 0
      api/server/handlers/environment/delete.go
  22. 128 0
      api/server/handlers/environment/validate_porter_yaml.go
  23. 1 38
      api/server/handlers/gitinstallation/webhook.go
  24. 37 0
      api/server/handlers/infra/forms.go
  25. 0 5
      api/server/handlers/infra/stream_logs.go
  26. 0 409
      api/server/handlers/kube_events/create.go
  27. 0 96
      api/server/handlers/kube_events/get_log_buckets.go
  28. 0 97
      api/server/handlers/kube_events/get_logs.go
  29. 1 1
      api/server/handlers/namespace/create_env_group.go
  30. 73 0
      api/server/handlers/namespace/stream_pod_logs_loki.go
  31. 1 0
      api/server/handlers/project_integration/create_aws.go
  32. 6 1
      api/server/handlers/registry/list_images.go
  33. 1 1
      api/server/handlers/release/create.go
  34. 1 1
      api/server/handlers/release/create_addon.go
  35. 13 1
      api/server/handlers/release/update_image_batch.go
  36. 12 9
      api/server/handlers/release/upgrade.go
  37. 9 8
      api/server/handlers/release/upgrade_webhook.go
  38. 9 8
      api/server/handlers/stack/add_application.go
  39. 9 8
      api/server/handlers/stack/create.go
  40. 17 5
      api/server/handlers/stack/helpers.go
  41. 18 8
      api/server/handlers/stack/update_source_put.go
  42. 1 1
      api/server/handlers/v1/env_group/create.go
  43. 9 8
      api/server/handlers/v1/release/upgrade.go
  44. 194 83
      api/server/router/cluster.go
  45. 34 0
      api/server/router/namespace.go
  46. 12 6
      api/server/shared/config/env/envconfs.go
  47. 5 3
      api/server/shared/config/loader/loader.go
  48. 15 10
      api/server/shared/config/metadata.go
  49. 8 2
      api/types/agent.go
  50. 6 15
      api/types/cluster.go
  51. 8 0
      api/types/environment.go
  52. 193 0
      api/types/incident.go
  53. 1 0
      api/types/project_integration.go
  54. 76 57
      cli/cmd/apply.go
  55. 11 11
      cli/cmd/config.go
  56. 3 3
      cli/cmd/config/config.go
  57. 2 1
      cli/cmd/connect/ecr.go
  58. 7 7
      cli/cmd/connect/kubeconfig.go
  59. 3 2
      cli/cmd/errors.go
  60. 1 1
      cli/cmd/list.go
  61. 6 26
      cli/cmd/preview/build_image_driver.go
  62. 5 8
      cli/cmd/preview/env_group_driver.go
  63. 5 15
      cli/cmd/preview/push_image_driver.go
  64. 4 8
      cli/cmd/preview/random_string_driver.go
  65. 6 26
      cli/cmd/preview/update_config_driver.go
  66. 5 19
      cli/cmd/preview/utils.go
  67. 28 7
      cli/cmd/run.go
  68. 1 1
      cli/cmd/utils/close.go
  69. 1 14275
      dashboard/package-lock.json
  70. 7 1
      dashboard/package.json
  71. 1 0
      dashboard/src/App.tsx
  72. 0 4
      dashboard/src/assets/Iconly/Bulk/Info Square.svg
  73. 3 0
      dashboard/src/assets/arrow-down.svg
  74. BIN
      dashboard/src/assets/azure.png
  75. 4 0
      dashboard/src/assets/danger.svg
  76. 6 0
      dashboard/src/assets/document.svg
  77. 4 0
      dashboard/src/assets/down-arrow.svg
  78. 3 0
      dashboard/src/assets/filter-outline.svg
  79. 4 0
      dashboard/src/assets/folder-outline.svg
  80. 5 0
      dashboard/src/assets/info-circle.svg
  81. 5 0
      dashboard/src/assets/info-outlined.svg
  82. 4 0
      dashboard/src/assets/last-run.svg
  83. 4 0
      dashboard/src/assets/left-arrow.svg
  84. 6 0
      dashboard/src/assets/sort.svg
  85. 6 0
      dashboard/src/assets/tag.svg
  86. 4 0
      dashboard/src/assets/time.svg
  87. 14 4
      dashboard/src/components/Banner.tsx
  88. 3 1
      dashboard/src/components/Boilerplate.tsx
  89. 0 1
      dashboard/src/components/Button.tsx
  90. 126 0
      dashboard/src/components/CheckboxList.tsx
  91. 73 0
      dashboard/src/components/CheckboxRow.tsx
  92. 10 0
      dashboard/src/components/Loading.tsx
  93. 0 2
      dashboard/src/components/MultiSaveButton.tsx
  94. 202 0
      dashboard/src/components/MultiSelectFilter.tsx
  95. 2 4
      dashboard/src/components/ProvisionerStatus.tsx
  96. 220 0
      dashboard/src/components/RadioFilter.tsx
  97. 0 2
      dashboard/src/components/SaveButton.tsx
  98. 1 1
      dashboard/src/components/Selector.tsx
  99. 3 3
      dashboard/src/components/Table.tsx
  100. 4 3
      dashboard/src/components/TitleSection.tsx

+ 2 - 0
.github/workflows/production.yaml

@@ -48,6 +48,8 @@ jobs:
           ENABLE_SENTRY=true
           SENTRY_DSN=${{secrets.SENTRY_DSN}}
           SENTRY_ENV=frontend-production
+          ZAPIER_WEBHOOK_URL=${{secrets.ZAPIER_WEBHOOK_URL}}
+          DISCORD_WEBHOOK_URL=${{secrets.DISCORD_WEBHOOK_URL}}
           EOL
       - name: Build
         run: |

+ 2 - 0
.github/workflows/staging.yaml

@@ -47,6 +47,8 @@ jobs:
           ENABLE_SENTRY=true
           SENTRY_DSN=${{secrets.SENTRY_DSN}}
           SENTRY_ENV=frontend-staging
+          ZAPIER_WEBHOOK_URL=${{secrets.ZAPIER_WEBHOOK_URL}}
+          DISCORD_WEBHOOK_URL=${{secrets.DISCORD_WEBHOOK_URL}}
           EOL
       - name: Build
         run: |

+ 4 - 4
api/client/api.go

@@ -150,9 +150,9 @@ func (c *Client) postRequest(relPath string, data interface{}, response interfac
 
 		if i != int(retryCount)-1 {
 			if httpErr != nil {
-				fmt.Printf("Error: %s (status code %d), retrying request...\n", httpErr.Error, httpErr.Code)
+				fmt.Fprintf(os.Stderr, "Error: %s (status code %d), retrying request...\n", httpErr.Error, httpErr.Code)
 			} else {
-				fmt.Printf("Error: %v, retrying request...\n", err)
+				fmt.Fprintf(os.Stderr, "Error: %v, retrying request...\n", err)
 			}
 		}
 	}
@@ -205,9 +205,9 @@ func (c *Client) patchRequest(relPath string, data interface{}, response interfa
 
 		if i != int(retryCount)-1 {
 			if httpErr != nil {
-				fmt.Printf("Error: %s (status code %d), retrying request...\n", httpErr.Error, httpErr.Code)
+				fmt.Fprintf(os.Stderr, "Error: %s (status code %d), retrying request...\n", httpErr.Error, httpErr.Code)
 			} else {
-				fmt.Printf("Error: %v, retrying request...\n", err)
+				fmt.Fprintf(os.Stderr, "Error: %v, retrying request...\n", err)
 			}
 		}
 	}

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

+ 1 - 0
api/server/handlers/environment/create.go

@@ -177,6 +177,7 @@ func (c *CreateEnvironmentHandler) ServeHTTP(w http.ResponseWriter, r *http.Requ
 		ClusterID:         cluster.ID,
 		GitInstallationID: uint(ga.InstallationID),
 		EnvironmentName:   request.Name,
+		InstanceName:      c.Config().ServerConf.InstanceName,
 	})
 
 	if err != nil {

+ 1 - 0
api/server/handlers/environment/delete.go

@@ -129,6 +129,7 @@ func (c *DeleteEnvironmentHandler) ServeHTTP(w http.ResponseWriter, r *http.Requ
 		ClusterID:         cluster.ID,
 		GitInstallationID: uint(ga.InstallationID),
 		EnvironmentName:   env.Name,
+		InstanceName:      c.Config().ServerConf.InstanceName,
 	})
 
 	if err != nil {

+ 128 - 0
api/server/handlers/environment/validate_porter_yaml.go

@@ -0,0 +1,128 @@
+package environment
+
+import (
+	"context"
+	"errors"
+	"fmt"
+	"net/http"
+	"strings"
+
+	"github.com/google/go-github/v41/github"
+	"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/integrations/preview"
+	"github.com/porter-dev/porter/internal/models"
+	"gorm.io/gorm"
+)
+
+type ValidatePorterYAMLHandler struct {
+	handlers.PorterHandlerReadWriter
+	authz.KubernetesAgentGetter
+}
+
+func NewValidatePorterYAMLHandler(
+	config *config.Config,
+	decoderValidator shared.RequestDecoderValidator,
+	writer shared.ResultWriter,
+) *ValidatePorterYAMLHandler {
+	return &ValidatePorterYAMLHandler{
+		PorterHandlerReadWriter: handlers.NewDefaultPorterHandler(config, decoderValidator, writer),
+		KubernetesAgentGetter:   authz.NewOutOfClusterAgentGetter(config),
+	}
+}
+
+func (c *ValidatePorterYAMLHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	project, _ := r.Context().Value(types.ProjectScope).(*models.Project)
+	cluster, _ := r.Context().Value(types.ClusterScope).(*models.Cluster)
+
+	envID, reqErr := requestutils.GetURLParamUint(r, "environment_id")
+
+	if reqErr != nil {
+		c.HandleAPIError(w, r, reqErr)
+		return
+	}
+
+	req := &types.ValidatePorterYAMLRequest{}
+
+	if ok := c.DecodeAndValidate(w, r, req); !ok {
+		return
+	}
+
+	env, err := c.Repo().Environment().ReadEnvironmentByID(project.ID, cluster.ID, envID)
+
+	if err != nil {
+		if errors.Is(err, gorm.ErrRecordNotFound) {
+			c.HandleAPIError(w, r, apierrors.NewErrNotFound(fmt.Errorf("no such environment with ID: %d", envID)))
+			return
+		}
+
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(fmt.Errorf("error reading environment with ID: %d. Error: %w", envID, err)))
+		return
+	}
+
+	ghClient, err := getGithubClientFromEnvironment(c.Config(), env)
+
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	res := &types.ValidatePorterYAMLResponse{
+		Errors: []string{},
+	}
+
+	if req.Branch == "" { // get the default branch name
+		repo, _, err := ghClient.Repositories.Get(r.Context(), env.GitRepoOwner, env.GitRepoName)
+
+		if err != nil {
+			c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+			return
+		}
+
+		req.Branch = repo.GetDefaultBranch()
+	}
+
+	fileContents, _, ghResp, err := ghClient.Repositories.GetContents(
+		context.Background(), env.GitRepoOwner, env.GitRepoName, "porter.yaml",
+		&github.RepositoryContentGetOptions{
+			Ref: req.Branch,
+		},
+	)
+
+	if ghResp.StatusCode == 404 {
+		res.Errors = append(res.Errors, preview.ErrNoPorterYAMLFile.Error())
+		c.WriteResult(w, r, res)
+		return
+	}
+
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	contents, err := fileContents.GetContent()
+
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	if strings.TrimSpace(contents) == "" {
+		res.Errors = append(res.Errors, preview.ErrEmptyPorterYAMLFile.Error())
+		c.WriteResult(w, r, res)
+		return
+	}
+
+	for _, err := range preview.Validate(contents) {
+		if err != nil {
+			res.Errors = append(res.Errors, err.Error())
+		}
+	}
+
+	c.WriteResult(w, r, res)
+}

+ 1 - 38
api/server/handlers/gitinstallation/webhook.go

@@ -1,12 +1,7 @@
 package gitinstallation
 
 import (
-	"crypto/hmac"
-	"crypto/sha256"
-	"encoding/hex"
-	"io/ioutil"
 	"net/http"
-	"strings"
 
 	"github.com/google/go-github/v41/github"
 	"github.com/porter-dev/porter/api/server/authz"
@@ -35,21 +30,13 @@ func NewGithubAppWebhookHandler(
 }
 
 func (c *GithubAppWebhookHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
-	payload, err := ioutil.ReadAll(r.Body)
+	payload, err := github.ValidatePayload(r, []byte(c.Config().GithubAppConf.WebhookSecret))
 
 	if err != nil {
 		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
 		return
 	}
 
-	// verify webhook secret
-	signature := r.Header.Get("X-Hub-Signature-256")
-
-	if !verifySignature([]byte(c.Config().GithubAppConf.WebhookSecret), signature, payload) {
-		c.HandleAPIError(w, r, apierrors.NewErrForbidden(err))
-		return
-	}
-
 	event, err := github.ParseWebHook(github.WebHookType(r), payload)
 
 	if err != nil {
@@ -89,27 +76,3 @@ func (c *GithubAppWebhookHandler) ServeHTTP(w http.ResponseWriter, r *http.Reque
 		}
 	}
 }
-
-// verifySignature verifies a signature based on hmac protocal
-// https://docs.github.com/en/developers/webhooks-and-events/webhooks/securing-your-webhooks
-func verifySignature(secret []byte, signature string, body []byte) bool {
-	if len(signature) != 71 || !strings.HasPrefix(signature, "sha256=") {
-		return false
-	}
-
-	actual := make([]byte, 32)
-	_, err := hex.Decode(actual, []byte(signature[7:]))
-
-	if err != nil {
-		return false
-	}
-
-	computed := hmac.New(sha256.New, secret)
-	_, err = computed.Write(body)
-
-	if err != nil {
-		return false
-	}
-
-	return hmac.Equal(computed.Sum(nil), actual)
-}

+ 37 - 0
api/server/handlers/infra/forms.go

@@ -408,6 +408,16 @@ tabs:
           value: c6i.xlarge
         - label: c6i.2xlarge
           value: c6i.2xlarge
+        - label: c6i.4xlarge
+          value: c6i.4xlarge
+        - label: m6i.large
+          value: m6i.large
+        - label: m6i.xlarge
+          value: m6i.xlarge
+        - label: m6i.2xlarge
+          value: m6i.2xlarge
+        - label: m6i.4xlarge
+          value: m6i.4xlarge
         - label: r5.large
           value: r5.large
         - value: r5.xlarge
@@ -500,8 +510,22 @@ tabs:
           value: t3.xlarge
         - label: t3.2xlarge
           value: t3.2xlarge
+        - label: c6i.large
+          value: c6i.large
+        - label: c6i.xlarge
+          value: c6i.xlarge
         - label: c6i.2xlarge
           value: c6i.2xlarge
+        - label: c6i.4xlarge
+          value: c6i.4xlarge
+        - label: m6i.large
+          value: m6i.large
+        - label: m6i.xlarge
+          value: m6i.xlarge
+        - label: m6i.2xlarge
+          value: m6i.2xlarge
+        - label: m6i.4xlarge
+          value: m6i.4xlarge
     - type: number-input
       label: Minimum number of EC2 instances to create in the application autoscaling group.
       variable: additional_nodegroup_min_instances
@@ -621,6 +645,19 @@ tabs:
       variable: additional_private_subnets_multiplicity
       settings:
         default: 3
+  - name: net_settings_azs_toggle
+    contents:
+    - type: checkbox
+      label: "Specify the AZs to provision this cluster in."
+      variable: specify_azs
+      settings:
+        default: false
+  - name: net_settings_azs
+    show_if: specify_azs
+    contents:
+    - type: array-input
+      variable: azs
+      label: Availability Zones
   - name: nginx_settings
     contents:
     - type: heading

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

+ 1 - 0
api/server/handlers/project_integration/create_aws.go

@@ -57,6 +57,7 @@ func CreateAWSIntegration(request *types.CreateAWSRequest, projectID, userID uin
 		UserID:             userID,
 		ProjectID:          projectID,
 		AWSRegion:          request.AWSRegion,
+		AWSAssumeRoleArn:   request.AWSAssumeRoleArn,
 		AWSClusterID:       []byte(request.AWSClusterID),
 		AWSAccessKeyID:     []byte(request.AWSAccessKeyID),
 		AWSSecretAccessKey: []byte(request.AWSSecretAccessKey),

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

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

@@ -81,6 +81,12 @@ func (c *UpdateImageBatchHandler) ServeHTTP(w http.ResponseWriter, r *http.Reque
 			rel, err := helmAgent.GetRelease(releases[index].Name, 0, false)
 
 			if err != nil {
+				// if this is a release not found error, just return - the release has likely been deleted from the underlying
+				// cluster but has not been deleted from the Porter database yet
+				if strings.Contains(err.Error(), "release: not found") {
+					return
+				}
+
 				mu.Lock()
 				errors = append(errors, fmt.Sprintf("Error for %s, index %d: %s", releases[index].Name, index, err.Error()))
 				mu.Unlock()
@@ -102,9 +108,15 @@ 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
+					// cluster in the time since we've read the release, but has not been deleted from the Porter database yet
+					if strings.Contains(err.Error(), "release: not found") {
+						return
+					}
+
 					mu.Lock()
 					errors = append(errors, fmt.Sprintf("Error for %s, index %d: %s", releases[index].Name, index, err.Error()))
 					mu.Unlock()

+ 12 - 9
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"
 )
@@ -152,13 +153,15 @@ func (c *UpgradeReleaseHandler) ServeHTTP(w http.ResponseWriter, r *http.Request
 	for _, stk := range stacks {
 		for _, res := range stk.Revisions[0].Resources {
 			if res.Name == helmRelease.Name {
-				conf.Stack = stk
+				conf.StackName = stk.Name
+				conf.StackRevision = stk.Revisions[0].RevisionNumber + 1
 				break
 			}
 		}
 	}
 
-	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
@@ -181,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,
@@ -200,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(
@@ -216,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)
 		}
 	}
 

+ 9 - 8
api/server/handlers/stack/add_application.go

@@ -145,14 +145,15 @@ func (p *StackAddApplicationHandler) ServeHTTP(w http.ResponseWriter, r *http.Re
 
 	for _, appResource := range newResources {
 		rel, err := applyAppResource(&applyAppResourceOpts{
-			config:     p.Config(),
-			projectID:  proj.ID,
-			namespace:  namespace,
-			cluster:    cluster,
-			registries: registries,
-			helmAgent:  helmAgent,
-			request:    req,
-			stack:      stack,
+			config:        p.Config(),
+			projectID:     proj.ID,
+			namespace:     namespace,
+			cluster:       cluster,
+			registries:    registries,
+			helmAgent:     helmAgent,
+			request:       req,
+			stackName:     stack.Name,
+			stackRevision: stack.Revisions[0].RevisionNumber,
 		})
 
 		if err != nil {

+ 9 - 8
api/server/handlers/stack/create.go

@@ -191,14 +191,15 @@ func (p *StackCreateHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
 
 		for _, appResource := range req.AppResources {
 			rel, err := applyAppResource(&applyAppResourceOpts{
-				config:     p.Config(),
-				projectID:  proj.ID,
-				namespace:  namespace,
-				cluster:    cluster,
-				registries: registries,
-				helmAgent:  helmAgent,
-				request:    appResource,
-				stack:      stack,
+				config:        p.Config(),
+				projectID:     proj.ID,
+				namespace:     namespace,
+				cluster:       cluster,
+				registries:    registries,
+				helmAgent:     helmAgent,
+				request:       appResource,
+				stackName:     stack.Name,
+				stackRevision: stack.Revisions[0].RevisionNumber,
 			})
 
 			if err != nil {

+ 17 - 5
api/server/handlers/stack/helpers.go

@@ -17,7 +17,10 @@ type applyAppResourceOpts struct {
 	helmAgent  *helm.Agent
 	request    *types.CreateStackAppResourceRequest
 	registries []*models.Registry
-	stack      *models.Stack
+
+	// stack related info
+	stackName     string
+	stackRevision uint
 }
 
 func applyAppResource(opts *applyAppResourceOpts) (*release.Release, error) {
@@ -47,11 +50,11 @@ func applyAppResource(opts *applyAppResourceOpts) (*release.Release, error) {
 
 	conf.Values["stack"] = map[string]interface{}{
 		"enabled":  true,
-		"name":     opts.stack.Name,
-		"revision": opts.stack.Revisions[0].RevisionNumber,
+		"name":     opts.stackName,
+		"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 {
@@ -72,6 +75,10 @@ type updateAppResourceTagOpts struct {
 	namespace  string
 	cluster    *models.Cluster
 	registries []*models.Registry
+
+	// stack related info
+	stackName     string
+	stackRevision uint
 }
 
 func updateAppResourceTag(opts *updateAppResourceTagOpts) error {
@@ -93,9 +100,14 @@ func updateAppResourceTag(opts *updateAppResourceTagOpts) error {
 		Repo:       opts.config.Repo,
 		Registries: opts.registries,
 		Values:     rel.Config,
+
+		// stack related info
+		StackName:     opts.stackName,
+		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
 }

+ 18 - 8
api/server/handlers/stack/update_source_put.go

@@ -98,6 +98,14 @@ func (p *StackPutSourceConfigHandler) ServeHTTP(w http.ResponseWriter, r *http.R
 
 	deployErrs := make([]string, 0)
 
+	// read the stack again to get the latest revision info
+	stack, err = p.Repo().Stack().ReadStackByStringID(proj.ID, stack.UID)
+
+	if err != nil {
+		p.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
 	for i, appResource := range clonedAppResources {
 		// get the corresponding source config tag
 		var imageTag string
@@ -111,14 +119,16 @@ func (p *StackPutSourceConfigHandler) ServeHTTP(w http.ResponseWriter, r *http.R
 		// TODO: case on if image tag is empty
 
 		err = updateAppResourceTag(&updateAppResourceTagOpts{
-			helmAgent:  helmAgent,
-			name:       appResource.Name,
-			tag:        imageTag,
-			config:     p.Config(),
-			projectID:  proj.ID,
-			namespace:  namespace,
-			cluster:    cluster,
-			registries: registries,
+			helmAgent:     helmAgent,
+			name:          appResource.Name,
+			tag:           imageTag,
+			config:        p.Config(),
+			projectID:     proj.ID,
+			namespace:     namespace,
+			cluster:       cluster,
+			registries:    registries,
+			stackName:     stack.Name,
+			stackRevision: stack.Revisions[0].RevisionNumber,
 		})
 
 		if err != nil {

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

+ 194 - 83
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"
@@ -347,7 +346,7 @@ func getClusterRoutes(
 			Router:   r,
 		})
 
-		// PATCH /api/projects/{project_id}/clusters/{cluster_id}/environment/{environment_id}/toggle_new_comment -> environment.NewToggleNewCommentHandler
+		// PATCH /api/projects/{project_id}/clusters/{cluster_id}/environments/{environment_id}/toggle_new_comment -> environment.NewToggleNewCommentHandler
 		toggleNewCommentEndpoint := factory.NewAPIEndpoint(
 			&types.APIRequestMetadata{
 				Verb:   types.APIVerbUpdate,
@@ -376,6 +375,35 @@ func getClusterRoutes(
 			Router:   r,
 		})
 
+		// GET /api/projects/{project_id}/clusters/{cluster_id}/environments/{environment_id}/validate_porter_yaml -> environment.NewValidatePorterYAMLHandler
+		validtatePorterYAMLEndpoint := factory.NewAPIEndpoint(
+			&types.APIRequestMetadata{
+				Verb:   types.APIVerbGet,
+				Method: types.HTTPVerbGet,
+				Path: &types.Path{
+					Parent:       basePath,
+					RelativePath: relPath + "/environments/{environment_id}/validate_porter_yaml",
+				},
+				Scopes: []types.PermissionScope{
+					types.UserScope,
+					types.ProjectScope,
+					types.ClusterScope,
+				},
+			},
+		)
+
+		validatePorterYAMLHandler := environment.NewValidatePorterYAMLHandler(
+			config,
+			factory.GetDecoderValidator(),
+			factory.GetResultWriter(),
+		)
+
+		routes = append(routes, &router.Route{
+			Endpoint: validtatePorterYAMLEndpoint,
+			Handler:  validatePorterYAMLHandler,
+			Router:   r,
+		})
+
 		// GET /api/projects/{project_id}/clusters/{cluster_id}/deployments -> environment.NewListDeploymentsByClusterHandler
 		listDeploymentsEndpoint := factory.NewAPIEndpoint(
 			&types.APIRequestMetadata{
@@ -801,6 +829,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 +883,42 @@ 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 + "/prometheus/ingresses",
+			},
+			Scopes: []types.PermissionScope{
+				types.UserScope,
+				types.ProjectScope,
+				types.ClusterScope,
+			},
+		},
+	)
+
+	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 + "/kube_events",
+				RelativePath: relPath + "/metrics",
 			},
 			Scopes: []types.PermissionScope{
 				types.UserScope,
@@ -847,55 +928,90 @@ func getClusterRoutes(
 		},
 	)
 
-	listKubeEventsHandler := kube_events.NewListKubeEventsHandler(
+	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,
+		},
+	)
+
+	streamHelmReleaseHandler := cluster.NewStreamHelmReleaseHandler(
+		config,
+		factory.GetDecoderValidator(),
+		factory.GetResultWriter(),
+	)
+
+	routes = append(routes, &router.Route{
+		Endpoint: streamHelmReleaseEndpoint,
+		Handler:  streamHelmReleaseHandler,
+		Router:   r,
+	})
+
+	// 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,
 		},
 	)
 
-	getKubeEventHandler := kube_events.NewGetKubeEventHandler(
+	streamStatusHandler := cluster.NewStreamStatusHandler(
 		config,
 		factory.GetDecoderValidator(),
 		factory.GetResultWriter(),
 	)
 
 	routes = append(routes, &router.Route{
-		Endpoint: getKubeEventEndpoint,
-		Handler:  getKubeEventHandler,
+		Endpoint: streamStatusEndpoint,
+		Handler:  streamStatusHandler,
 		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}/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 +1021,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 +1050,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 +1079,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 +1108,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 +1137,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 +1224,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 +1253,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 +1282,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,
 		})
 	}
 

+ 15 - 10
api/server/shared/config/metadata.go

@@ -15,20 +15,25 @@ type Metadata struct {
 	Analytics          bool   `json:"analytics"`
 	Version            string `json:"version"`
 	Gitlab             bool   `json:"gitlab"`
+
+	DefaultAppHelmRepoURL   string `json:"default_app_helm_repo_url"`
+	DefaultAddonHelmRepoURL string `json:"default_addon_helm_repo_url"`
 }
 
 func MetadataFromConf(sc *env.ServerConf, version string) *Metadata {
 	return &Metadata{
-		Provisioning:       sc.ProvisionerServerURL != "" && sc.ProvisionerToken != "",
-		Github:             hasGithubAppVars(sc),
-		GithubLogin:        sc.GithubClientID != "" && sc.GithubClientSecret != "" && sc.GithubLoginEnabled,
-		BasicLogin:         sc.BasicLoginEnabled,
-		GoogleLogin:        sc.GoogleClientID != "" && sc.GoogleClientSecret != "",
-		SlackNotifications: sc.SlackClientID != "" && sc.SlackClientSecret != "",
-		Email:              sc.SendgridAPIKey != "",
-		Analytics:          sc.SegmentClientKey != "",
-		Version:            version,
-		Gitlab:             sc.EnableGitlab,
+		Provisioning:            sc.ProvisionerServerURL != "" && sc.ProvisionerToken != "",
+		Github:                  hasGithubAppVars(sc),
+		GithubLogin:             sc.GithubClientID != "" && sc.GithubClientSecret != "" && sc.GithubLoginEnabled,
+		BasicLogin:              sc.BasicLoginEnabled,
+		GoogleLogin:             sc.GoogleClientID != "" && sc.GoogleClientSecret != "",
+		SlackNotifications:      sc.SlackClientID != "" && sc.SlackClientSecret != "",
+		Email:                   sc.SendgridAPIKey != "",
+		Analytics:               sc.SegmentClientKey != "",
+		Version:                 version,
+		Gitlab:                  sc.EnableGitlab,
+		DefaultAppHelmRepoURL:   sc.DefaultApplicationHelmRepoURL,
+		DefaultAddonHelmRepoURL: sc.DefaultAddonHelmRepoURL,
 	}
 }
 

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

+ 8 - 0
api/types/environment.go

@@ -129,3 +129,11 @@ type ToggleNewCommentRequest struct {
 }
 
 type ListEnvironmentsResponse []*Environment
+
+type ValidatePorterYAMLRequest struct {
+	Branch string `schema:"branch"`
+}
+
+type ValidatePorterYAMLResponse struct {
+	Errors []string `json:"errors"`
+}

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

+ 1 - 0
api/types/project_integration.go

@@ -80,6 +80,7 @@ type CreateAWSRequest struct {
 	AWSClusterID       string `json:"aws_cluster_id"`
 	AWSAccessKeyID     string `json:"aws_access_key_id"`
 	AWSSecretAccessKey string `json:"aws_secret_access_key"`
+	AWSAssumeRoleArn   string `json:"aws_assume_role_arn"`
 }
 
 type CreateAWSResponse struct {

+ 76 - 57
cli/cmd/apply.go

@@ -20,6 +20,7 @@ import (
 	"github.com/porter-dev/porter/cli/cmd/deploy"
 	"github.com/porter-dev/porter/cli/cmd/deploy/wait"
 	"github.com/porter-dev/porter/cli/cmd/preview"
+	previewInt "github.com/porter-dev/porter/internal/integrations/preview"
 	"github.com/porter-dev/porter/internal/templater/utils"
 	"github.com/porter-dev/switchboard/pkg/drivers"
 	"github.com/porter-dev/switchboard/pkg/models"
@@ -71,16 +72,43 @@ applying a configuration:
 	},
 }
 
+// applyValidateCmd represents the "porter apply validate" command when called
+// with a porter.yaml file as an argument
+var applyValidateCmd = &cobra.Command{
+	Use:   "validate",
+	Short: "Validates a porter.yaml",
+	Run: func(*cobra.Command, []string) {
+		err := applyValidate()
+
+		if err != nil {
+			color.New(color.FgRed).Fprintf(os.Stderr, "Error: %s\n", err.Error())
+			os.Exit(1)
+		} else {
+			color.New(color.FgGreen).Printf("The porter.yaml file is valid!\n")
+		}
+	},
+}
+
 var porterYAML string
 
 func init() {
 	rootCmd.AddCommand(applyCmd)
 
-	applyCmd.Flags().StringVarP(&porterYAML, "file", "f", "", "path to porter.yaml")
+	applyCmd.AddCommand(applyValidateCmd)
+
+	applyCmd.PersistentFlags().StringVarP(&porterYAML, "file", "f", "", "path to porter.yaml")
 	applyCmd.MarkFlagRequired("file")
 }
 
 func apply(_ *types.GetAuthenticatedUserResponse, client *api.Client, _ []string) error {
+	if _, ok := os.LookupEnv("PORTER_VALIDATE_YAML"); ok {
+		err := applyValidate()
+
+		if err != nil {
+			return err
+		}
+	}
+
 	fileBytes, err := ioutil.ReadFile(porterYAML)
 
 	if err != nil {
@@ -134,6 +162,28 @@ func apply(_ *types.GetAuthenticatedUserResponse, client *api.Client, _ []string
 	})
 }
 
+func applyValidate() error {
+	fileBytes, err := ioutil.ReadFile(porterYAML)
+
+	if err != nil {
+		return fmt.Errorf("error reading porter.yaml: %w", err)
+	}
+
+	validationErrors := previewInt.Validate(string(fileBytes))
+
+	if len(validationErrors) > 0 {
+		errString := "the following error(s) were found while validating the porter.yaml file:"
+
+		for _, err := range validationErrors {
+			errString += "\n- " + strings.ReplaceAll(err.Error(), "\n\n*", "\n  *")
+		}
+
+		return fmt.Errorf(errString)
+	}
+
+	return nil
+}
+
 func hasDeploymentHookEnvVars() bool {
 	if ghIDStr := os.Getenv("PORTER_GIT_INSTALLATION_ID"); ghIDStr == "" {
 		return false
@@ -170,32 +220,9 @@ func hasDeploymentHookEnvVars() bool {
 	return true
 }
 
-type ApplicationConfig struct {
-	WaitForJob bool
-
-	// If set to true, this does not run an update, it only creates the initial application and job,
-	// skipping subsequent updates
-	OnlyCreate bool
-
-	Build struct {
-		UseCache   bool `mapstructure:"use_cache"`
-		Method     string
-		Context    string
-		Dockerfile string
-		Image      string
-		Builder    string
-		Buildpacks []string
-		Env        map[string]string
-	}
-
-	EnvGroups []types.EnvGroupMeta `mapstructure:"env_groups"`
-
-	Values map[string]interface{}
-}
-
 type DeployDriver struct {
-	source      *preview.Source
-	target      *preview.Target
+	source      *previewInt.Source
+	target      *previewInt.Target
 	output      map[string]interface{}
 	lookupTable *map[string]drivers.Driver
 	logger      *zerolog.Logger
@@ -233,11 +260,6 @@ func (d *DeployDriver) ShouldApply(_ *models.Resource) bool {
 
 func (d *DeployDriver) Apply(resource *models.Resource) (*models.Resource, error) {
 	client := config.GetAPIClient()
-	name := resource.Name
-
-	if name == "" {
-		return nil, fmt.Errorf("empty resource name")
-	}
 
 	_, err := client.GetRelease(
 		context.Background(),
@@ -323,23 +345,18 @@ func (d *DeployDriver) applyApplication(resource *models.Resource, client *api.C
 		return nil, fmt.Errorf("nil resource")
 	}
 
+	resourceName := resource.Name
+
 	appConfig, err := d.getApplicationConfig(resource)
 
 	if err != nil {
 		return nil, err
 	}
 
-	method := appConfig.Build.Method
-
-	if method != "pack" && method != "docker" && method != "registry" {
-		return nil, fmt.Errorf("for resource %s, config.build.method should either be \"docker\", \"pack\" or \"registry\"",
-			resource.Name)
-	}
-
 	fullPath, err := filepath.Abs(appConfig.Build.Context)
 
 	if err != nil {
-		return nil, fmt.Errorf("for resource %s, error getting absolute path for config.build.context: %w", resource.Name,
+		return nil, fmt.Errorf("for resource %s, error getting absolute path for config.build.context: %w", resourceName,
 			err)
 	}
 
@@ -347,17 +364,17 @@ func (d *DeployDriver) applyApplication(resource *models.Resource, client *api.C
 
 	if tag == "" {
 		color.New(color.FgYellow).Printf("for resource %s, since PORTER_TAG is not set, the Docker image tag will default to"+
-			" the git repo SHA", resource.Name)
+			" the git repo SHA", resourceName)
 
 		commit, err := git.LastCommit()
 
 		if err != nil {
-			return nil, fmt.Errorf("for resource %s, error getting last git commit: %w", resource.Name, err)
+			return nil, fmt.Errorf("for resource %s, error getting last git commit: %w", resourceName, err)
 		}
 
 		tag = commit.Sha[:7]
 
-		color.New(color.FgYellow).Printf("for resource %s, using tag %s\n", resource.Name, tag)
+		color.New(color.FgYellow).Printf("for resource %s, using tag %s\n", resourceName, tag)
 	}
 
 	// if the method is registry and a tag is defined, we use the provided tag
@@ -380,7 +397,7 @@ func (d *DeployDriver) applyApplication(resource *models.Resource, client *api.C
 		LocalPath:       fullPath,
 		LocalDockerfile: appConfig.Build.Dockerfile,
 		OverrideTag:     tag,
-		Method:          deploy.DeployBuildType(method),
+		Method:          deploy.DeployBuildType(appConfig.Build.Method),
 		EnvGroups:       appConfig.EnvGroups,
 		UseCache:        appConfig.Build.UseCache,
 	}
@@ -398,16 +415,16 @@ func (d *DeployDriver) applyApplication(resource *models.Resource, client *api.C
 		resource, err = d.createApplication(resource, client, sharedOpts, appConfig)
 
 		if err != nil {
-			return nil, fmt.Errorf("error creating app from resource %s: %w", resource.Name, err)
+			return nil, fmt.Errorf("error creating app from resource %s: %w", resourceName, err)
 		}
 	} else if !appConfig.OnlyCreate {
 		resource, err = d.updateApplication(resource, client, sharedOpts, appConfig)
 
 		if err != nil {
-			return nil, fmt.Errorf("error updating application from resource %s: %w", resource.Name, err)
+			return nil, fmt.Errorf("error updating application from resource %s: %w", resourceName, err)
 		}
 	} else {
-		color.New(color.FgYellow).Printf("Skipping creation for resource %s as onlyCreate is set to true\n", resource.Name)
+		color.New(color.FgYellow).Printf("Skipping creation for resource %s as onlyCreate is set to true\n", resourceName)
 	}
 
 	if err = d.assignOutput(resource, client); err != nil {
@@ -415,13 +432,13 @@ func (d *DeployDriver) applyApplication(resource *models.Resource, client *api.C
 	}
 
 	if d.source.Name == "job" && appConfig.WaitForJob && (shouldCreate || !appConfig.OnlyCreate) {
-		color.New(color.FgYellow).Printf("Waiting for job '%s' to finish\n", resource.Name)
+		color.New(color.FgYellow).Printf("Waiting for job '%s' to finish\n", resourceName)
 
 		err = wait.WaitForJob(client, &wait.WaitOpts{
 			ProjectID: d.target.Project,
 			ClusterID: d.target.Cluster,
 			Namespace: d.target.Namespace,
-			Name:      resource.Name,
+			Name:      resourceName,
 		})
 
 		if err != nil && appConfig.OnlyCreate {
@@ -430,22 +447,22 @@ func (d *DeployDriver) applyApplication(resource *models.Resource, client *api.C
 				d.target.Project,
 				d.target.Cluster,
 				d.target.Namespace,
-				resource.Name,
+				resourceName,
 			)
 
 			if deleteJobErr != nil {
 				return nil, fmt.Errorf("error deleting job %s with waitForJob and onlyCreate set to true: %w",
-					resource.Name, deleteJobErr)
+					resourceName, deleteJobErr)
 			}
 		} else if err != nil {
-			return nil, fmt.Errorf("error waiting for job %s: %w", resource.Name, err)
+			return nil, fmt.Errorf("error waiting for job %s: %w", resourceName, err)
 		}
 	}
 
 	return resource, err
 }
 
-func (d *DeployDriver) createApplication(resource *models.Resource, client *api.Client, sharedOpts *deploy.SharedOpts, appConf *ApplicationConfig) (*models.Resource, error) {
+func (d *DeployDriver) createApplication(resource *models.Resource, client *api.Client, sharedOpts *deploy.SharedOpts, appConf *previewInt.ApplicationConfig) (*models.Resource, error) {
 	// create new release
 	color.New(color.FgGreen).Printf("Creating %s release: %s\n", d.source.Name, resource.Name)
 
@@ -531,7 +548,7 @@ func (d *DeployDriver) createApplication(resource *models.Resource, client *api.
 	return resource, handleSubdomainCreate(subdomain, err)
 }
 
-func (d *DeployDriver) updateApplication(resource *models.Resource, client *api.Client, sharedOpts *deploy.SharedOpts, appConf *ApplicationConfig) (*models.Resource, error) {
+func (d *DeployDriver) updateApplication(resource *models.Resource, client *api.Client, sharedOpts *deploy.SharedOpts, appConf *previewInt.ApplicationConfig) (*models.Resource, error) {
 	color.New(color.FgGreen).Println("Updating existing release:", resource.Name)
 
 	if len(appConf.Build.Env) > 0 {
@@ -619,7 +636,7 @@ func (d *DeployDriver) Output() (map[string]interface{}, error) {
 	return d.output, nil
 }
 
-func (d *DeployDriver) getApplicationConfig(resource *models.Resource) (*ApplicationConfig, error) {
+func (d *DeployDriver) getApplicationConfig(resource *models.Resource) (*previewInt.ApplicationConfig, error) {
 	populatedConf, err := drivers.ConstructConfig(&drivers.ConstructConfigOpts{
 		RawConf:      resource.Config,
 		LookupTable:  *d.lookupTable,
@@ -630,7 +647,7 @@ func (d *DeployDriver) getApplicationConfig(resource *models.Resource) (*Applica
 		return nil, err
 	}
 
-	appConf := &ApplicationConfig{}
+	appConf := &previewInt.ApplicationConfig{}
 
 	err = mapstructure.Decode(populatedConf, appConf)
 
@@ -745,7 +762,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
 		}
@@ -991,7 +1010,7 @@ func (t *CloneEnvGroupHook) PreApply() error {
 			continue
 		}
 
-		appConf := &ApplicationConfig{}
+		appConf := &previewInt.ApplicationConfig{}
 
 		err := mapstructure.Decode(res.Config, &appConf)
 		if err != nil {

+ 11 - 11
cli/cmd/config.go

@@ -26,7 +26,7 @@ var configCmd = &cobra.Command{
 	Short: "Commands that control local configuration settings",
 	Run: func(cmd *cobra.Command, args []string) {
 		if err := printConfig(); err != nil {
-			color.New(color.FgRed).Printf("An error occurred: %v\n", err)
+			color.New(color.FgRed).Fprintf(os.Stderr, "An error occurred: %v\n", err)
 			os.Exit(1)
 		}
 	},
@@ -47,14 +47,14 @@ var configSetProjectCmd = &cobra.Command{
 			projID, err := strconv.ParseUint(args[0], 10, 64)
 
 			if err != nil {
-				color.New(color.FgRed).Printf("An error occurred: %v\n", err)
+				color.New(color.FgRed).Fprintf(os.Stderr, "An error occurred: %v\n", err)
 				os.Exit(1)
 			}
 
 			err = cliConf.SetProject(uint(projID))
 
 			if err != nil {
-				color.New(color.FgRed).Printf("An error occurred: %v\n", err)
+				color.New(color.FgRed).Fprintf(os.Stderr, "An error occurred: %v\n", err)
 				os.Exit(1)
 			}
 		}
@@ -76,14 +76,14 @@ var configSetClusterCmd = &cobra.Command{
 			clusterID, err := strconv.ParseUint(args[0], 10, 64)
 
 			if err != nil {
-				color.New(color.FgRed).Printf("An error occurred: %v\n", err)
+				color.New(color.FgRed).Fprintf(os.Stderr, "An error occurred: %v\n", err)
 				os.Exit(1)
 			}
 
 			err = cliConf.SetCluster(uint(clusterID))
 
 			if err != nil {
-				color.New(color.FgRed).Printf("An error occurred: %v\n", err)
+				color.New(color.FgRed).Fprintf(os.Stderr, "An error occurred: %v\n", err)
 				os.Exit(1)
 			}
 		}
@@ -105,14 +105,14 @@ var configSetRegistryCmd = &cobra.Command{
 			registryID, err := strconv.ParseUint(args[0], 10, 64)
 
 			if err != nil {
-				color.New(color.FgRed).Printf("An error occurred: %v\n", err)
+				color.New(color.FgRed).Fprintf(os.Stderr, "An error occurred: %v\n", err)
 				os.Exit(1)
 			}
 
 			err = cliConf.SetRegistry(uint(registryID))
 
 			if err != nil {
-				color.New(color.FgRed).Printf("An error occurred: %v\n", err)
+				color.New(color.FgRed).Fprintf(os.Stderr, "An error occurred: %v\n", err)
 				os.Exit(1)
 			}
 		}
@@ -127,14 +127,14 @@ var configSetHelmRepoCmd = &cobra.Command{
 		hrID, err := strconv.ParseUint(args[0], 10, 64)
 
 		if err != nil {
-			color.New(color.FgRed).Printf("An error occurred: %v\n", err)
+			color.New(color.FgRed).Fprintf(os.Stderr, "An error occurred: %v\n", err)
 			os.Exit(1)
 		}
 
 		err = cliConf.SetHelmRepo(uint(hrID))
 
 		if err != nil {
-			color.New(color.FgRed).Printf("An error occurred: %v\n", err)
+			color.New(color.FgRed).Fprintf(os.Stderr, "An error occurred: %v\n", err)
 			os.Exit(1)
 		}
 	},
@@ -148,7 +148,7 @@ var configSetHostCmd = &cobra.Command{
 		err := cliConf.SetHost(args[0])
 
 		if err != nil {
-			color.New(color.FgRed).Printf("An error occurred: %v\n", err)
+			color.New(color.FgRed).Fprintf(os.Stderr, "An error occurred: %v\n", err)
 			os.Exit(1)
 		}
 	},
@@ -162,7 +162,7 @@ var configSetKubeconfigCmd = &cobra.Command{
 		err := cliConf.SetKubeconfig(args[0])
 
 		if err != nil {
-			color.New(color.FgRed).Printf("An error occurred: %v\n", err)
+			color.New(color.FgRed).Fprintf(os.Stderr, "An error occurred: %v\n", err)
 			os.Exit(1)
 		}
 	},

+ 3 - 3
cli/cmd/config/config.go

@@ -67,7 +67,7 @@ func initAndLoadConfig(_config *CLIConfig) {
 	if _, err := os.Stat(porterDir); os.IsNotExist(err) {
 		os.Mkdir(porterDir, 0700)
 	} else if err != nil {
-		color.New(color.FgRed).Printf("%v\n", err)
+		color.New(color.FgRed).Fprintf(os.Stderr, "%v\n", err)
 		os.Exit(1)
 	}
 
@@ -96,12 +96,12 @@ func initAndLoadConfig(_config *CLIConfig) {
 			err := ioutil.WriteFile(filepath.Join(home, ".porter", "porter.yaml"), []byte{}, 0644)
 
 			if err != nil {
-				color.New(color.FgRed).Printf("%v\n", err)
+				color.New(color.FgRed).Fprintf(os.Stderr, "%v\n", err)
 				os.Exit(1)
 			}
 		} else {
 			// Config file was found but another error was produced
-			color.New(color.FgRed).Printf("%v\n", err)
+			color.New(color.FgRed).Fprintf(os.Stderr, "%v\n", err)
 			os.Exit(1)
 		}
 	}

+ 2 - 1
cli/cmd/connect/ecr.go

@@ -3,6 +3,7 @@ package connect
 import (
 	"context"
 	"fmt"
+	"os"
 	"strings"
 	"time"
 
@@ -53,7 +54,7 @@ Would you like to proceed? %s `,
 		creds, err := agent.CreateIAMECRUser(region)
 
 		if err != nil {
-			color.New(color.FgRed).Printf("Automatic creation failed, manual input required. Error was: %v\n", err)
+			color.New(color.FgRed).Fprintf(os.Stderr, "Automatic creation failed, manual input required. Error was: %v\n", err)
 			return ecrManual(client, projectID, region)
 		}
 

+ 7 - 7
cli/cmd/connect/kubeconfig.go

@@ -351,14 +351,14 @@ Would you like to proceed? %s `,
 		agent, err := gcpLocal.NewDefaultAgent()
 
 		if err != nil {
-			color.New(color.FgRed).Printf("Automatic creation failed, manual input required. Error was: %v\n", err)
+			color.New(color.FgRed).Fprintf(os.Stderr, "Automatic creation failed, manual input required. Error was: %v\n", err)
 			return resolveGCPKeyActionManual(endpoint, clusterName, resolver)
 		}
 
 		projID, err := agent.GetProjectIDForGKECluster(endpoint)
 
 		if err != nil {
-			color.New(color.FgRed).Printf("Automatic creation failed, manual input required. Error was: %v\n", err)
+			color.New(color.FgRed).Fprintf(os.Stderr, "Automatic creation failed, manual input required. Error was: %v\n", err)
 			return resolveGCPKeyActionManual(endpoint, clusterName, resolver)
 		}
 
@@ -370,14 +370,14 @@ Would you like to proceed? %s `,
 		resp, err := agent.CreateServiceAccount(name)
 
 		if err != nil {
-			color.New(color.FgRed).Printf("Automatic creation failed, manual input required. Error was: %v\n", err)
+			color.New(color.FgRed).Fprintf(os.Stderr, "Automatic creation failed, manual input required. Error was: %v\n", err)
 			return resolveGCPKeyActionManual(endpoint, clusterName, resolver)
 		}
 
 		err = agent.SetServiceAccountIAMPolicy(resp)
 
 		if err != nil {
-			color.New(color.FgRed).Printf("Automatic creation failed, manual input required. Error was: %v\n", err)
+			color.New(color.FgRed).Fprintf(os.Stderr, "Automatic creation failed, manual input required. Error was: %v\n", err)
 			return resolveGCPKeyActionManual(endpoint, clusterName, resolver)
 		}
 
@@ -385,7 +385,7 @@ Would you like to proceed? %s `,
 		bytes, err := agent.CreateServiceAccountKey(resp)
 
 		if err != nil {
-			color.New(color.FgRed).Printf("Automatic creation failed, manual input required. Error was: %v\n", err)
+			color.New(color.FgRed).Fprintf(os.Stderr, "Automatic creation failed, manual input required. Error was: %v\n", err)
 			return resolveGCPKeyActionManual(endpoint, clusterName, resolver)
 		}
 
@@ -454,14 +454,14 @@ Would you like to proceed? %s `,
 		agent, err := awsLocal.NewDefaultKubernetesAgent(kubeconfigPath, contextName)
 
 		if err != nil {
-			color.New(color.FgRed).Printf("Automatic creation failed, manual input required. Error was: %v\n", err)
+			color.New(color.FgRed).Fprintf(os.Stderr, "Automatic creation failed, manual input required. Error was: %v\n", err)
 			return resolveAWSActionManual(endpoint, clusterName, awsClusterIDGuess, resolver)
 		}
 
 		creds, err := agent.CreateIAMKubernetesMapping(awsClusterIDGuess)
 
 		if err != nil {
-			color.New(color.FgRed).Printf("Automatic creation failed, manual input required. Error was: %v\n", err)
+			color.New(color.FgRed).Fprintf(os.Stderr, "Automatic creation failed, manual input required. Error was: %v\n", err)
 			return resolveAWSActionManual(endpoint, clusterName, awsClusterIDGuess, resolver)
 		}
 

+ 3 - 2
cli/cmd/errors.go

@@ -3,6 +3,7 @@ package cmd
 import (
 	"context"
 	"errors"
+	"os"
 	"strings"
 
 	"github.com/fatih/color"
@@ -32,7 +33,7 @@ func checkLoginAndRun(args []string, runner func(user *types.GetAuthenticatedUse
 			return ErrCannotConnect
 		}
 
-		red.Printf("Error: %v\n", err.Error())
+		red.Fprintf(os.Stderr, "Error: %v\n", err.Error())
 		return err
 	}
 
@@ -51,7 +52,7 @@ func checkLoginAndRun(args []string, runner func(user *types.GetAuthenticatedUse
 			return nil
 		}
 
-		red.Printf("Error: %v\n", err.Error())
+		red.Fprintf(os.Stderr, "Error: %v\n", err.Error())
 		return err
 	}
 

+ 1 - 1
cli/cmd/list.go

@@ -27,7 +27,7 @@ var listCmd = &cobra.Command{
 				os.Exit(1)
 			}
 		} else {
-			color.New(color.FgRed).Printf("invalid command: %s\n", args[0])
+			color.New(color.FgRed).Fprintf(os.Stderr, "invalid command: %s\n", args[0])
 		}
 	},
 }

+ 6 - 26
cli/cmd/preview/build_image_driver.go

@@ -14,31 +14,15 @@ import (
 	"github.com/porter-dev/porter/cli/cmd/config"
 	"github.com/porter-dev/porter/cli/cmd/deploy"
 	"github.com/porter-dev/porter/cli/cmd/docker"
+	"github.com/porter-dev/porter/internal/integrations/preview"
 	"github.com/porter-dev/switchboard/pkg/drivers"
 	"github.com/porter-dev/switchboard/pkg/models"
 )
 
-type BuildDriverConfig struct {
-	Build struct {
-		UsePackCache bool `mapstructure:"use_pack_cache"`
-		Method       string
-		Context      string
-		Dockerfile   string
-		Builder      string
-		Buildpacks   []string
-		Image        string
-		Env          map[string]string
-	}
-
-	EnvGroups []types.EnvGroupMeta `mapstructure:"env_groups"`
-
-	Values map[string]interface{}
-}
-
 type BuildDriver struct {
-	source      *Source
-	target      *Target
-	config      *BuildDriverConfig
+	source      *preview.Source
+	target      *preview.Target
+	config      *preview.BuildDriverConfig
 	lookupTable *map[string]drivers.Driver
 	output      map[string]interface{}
 }
@@ -61,10 +45,6 @@ func NewBuildDriver(resource *models.Resource, opts *drivers.SharedDriverOpts) (
 		return nil, err
 	}
 
-	if target.AppName == "" {
-		return nil, fmt.Errorf("target app_name is missing")
-	}
-
 	driver.target = target
 
 	return driver, nil
@@ -335,7 +315,7 @@ func (d *BuildDriver) Output() (map[string]interface{}, error) {
 	return d.output, nil
 }
 
-func (d *BuildDriver) getConfig(resource *models.Resource) (*BuildDriverConfig, error) {
+func (d *BuildDriver) getConfig(resource *models.Resource) (*preview.BuildDriverConfig, error) {
 	populatedConf, err := drivers.ConstructConfig(&drivers.ConstructConfigOpts{
 		RawConf:      resource.Config,
 		LookupTable:  *d.lookupTable,
@@ -346,7 +326,7 @@ func (d *BuildDriver) getConfig(resource *models.Resource) (*BuildDriverConfig,
 		return nil, err
 	}
 
-	config := &BuildDriverConfig{}
+	config := &preview.BuildDriverConfig{}
 
 	err = mapstructure.Decode(populatedConf, config)
 

+ 5 - 8
cli/cmd/preview/env_group_driver.go

@@ -8,19 +8,16 @@ import (
 	"github.com/mitchellh/mapstructure"
 	"github.com/porter-dev/porter/api/types"
 	"github.com/porter-dev/porter/cli/cmd/config"
+	"github.com/porter-dev/porter/internal/integrations/preview"
 	"github.com/porter-dev/switchboard/pkg/drivers"
 	"github.com/porter-dev/switchboard/pkg/models"
 )
 
-type EnvGroupDriverConfig struct {
-	EnvGroups []*types.EnvGroup `mapstructure:"env_groups"`
-}
-
 type EnvGroupDriver struct {
 	output      map[string]interface{}
 	lookupTable *map[string]drivers.Driver
-	target      *Target
-	config      *EnvGroupDriverConfig
+	target      *preview.Target
+	config      *preview.EnvGroupDriverConfig
 }
 
 func NewEnvGroupDriver(resource *models.Resource, opts *drivers.SharedDriverOpts) (drivers.Driver, error) {
@@ -112,7 +109,7 @@ func (d *EnvGroupDriver) Output() (map[string]interface{}, error) {
 	return d.output, nil
 }
 
-func (d *EnvGroupDriver) getConfig(resource *models.Resource) (*EnvGroupDriverConfig, error) {
+func (d *EnvGroupDriver) getConfig(resource *models.Resource) (*preview.EnvGroupDriverConfig, error) {
 	populatedConf, err := drivers.ConstructConfig(&drivers.ConstructConfigOpts{
 		RawConf:      resource.Config,
 		LookupTable:  *d.lookupTable,
@@ -123,7 +120,7 @@ func (d *EnvGroupDriver) getConfig(resource *models.Resource) (*EnvGroupDriverCo
 		return nil, err
 	}
 
-	config := &EnvGroupDriverConfig{}
+	config := &preview.EnvGroupDriverConfig{}
 
 	err = mapstructure.Decode(populatedConf, config)
 

+ 5 - 15
cli/cmd/preview/push_image_driver.go

@@ -11,20 +11,14 @@ import (
 	"github.com/porter-dev/porter/cli/cmd/config"
 	"github.com/porter-dev/porter/cli/cmd/deploy"
 	"github.com/porter-dev/porter/cli/cmd/docker"
+	"github.com/porter-dev/porter/internal/integrations/preview"
 	"github.com/porter-dev/switchboard/pkg/drivers"
 	"github.com/porter-dev/switchboard/pkg/models"
 )
 
-type PushDriverConfig struct {
-	Push struct {
-		UsePackCache bool `mapstructure:"use_pack_cache"`
-		Image        string
-	}
-}
-
 type PushDriver struct {
-	target      *Target
-	config      *PushDriverConfig
+	target      *preview.Target
+	config      *preview.PushDriverConfig
 	lookupTable *map[string]drivers.Driver
 	output      map[string]interface{}
 }
@@ -40,10 +34,6 @@ func NewPushDriver(resource *models.Resource, opts *drivers.SharedDriverOpts) (d
 		return nil, err
 	}
 
-	if target.AppName == "" {
-		return nil, fmt.Errorf("target app_name is missing")
-	}
-
 	driver.target = target
 
 	return driver, nil
@@ -157,7 +147,7 @@ func (d *PushDriver) Output() (map[string]interface{}, error) {
 	return d.output, nil
 }
 
-func (d *PushDriver) getConfig(resource *models.Resource) (*PushDriverConfig, error) {
+func (d *PushDriver) getConfig(resource *models.Resource) (*preview.PushDriverConfig, error) {
 	populatedConf, err := drivers.ConstructConfig(&drivers.ConstructConfigOpts{
 		RawConf:      resource.Config,
 		LookupTable:  *d.lookupTable,
@@ -168,7 +158,7 @@ func (d *PushDriver) getConfig(resource *models.Resource) (*PushDriverConfig, er
 		return nil, err
 	}
 
-	config := &PushDriverConfig{}
+	config := &preview.PushDriverConfig{}
 
 	err = mapstructure.Decode(populatedConf, config)
 

+ 4 - 8
cli/cmd/preview/random_string_driver.go

@@ -4,6 +4,7 @@ import (
 	"crypto/rand"
 
 	"github.com/mitchellh/mapstructure"
+	"github.com/porter-dev/porter/internal/integrations/preview"
 	"github.com/porter-dev/switchboard/pkg/drivers"
 	"github.com/porter-dev/switchboard/pkg/models"
 )
@@ -11,14 +12,9 @@ import (
 const defaultCharset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
 const lowerCharset = "abcdefghijklmnopqrstuvwxyz"
 
-type RandomStringDriverConfig struct {
-	Length int
-	Lower  bool
-}
-
 type RandomStringDriver struct {
 	output map[string]interface{}
-	config *RandomStringDriverConfig
+	config *preview.RandomStringDriverConfig
 }
 
 func NewRandomStringDriver(resource *models.Resource, opts *drivers.SharedDriverOpts) (drivers.Driver, error) {
@@ -26,7 +22,7 @@ func NewRandomStringDriver(resource *models.Resource, opts *drivers.SharedDriver
 		output: make(map[string]interface{}),
 	}
 
-	driverConfig := &RandomStringDriverConfig{}
+	driverConfig := &preview.RandomStringDriverConfig{}
 
 	err := mapstructure.Decode(resource.Config, driverConfig)
 
@@ -54,7 +50,7 @@ func (d *RandomStringDriver) Apply(resource *models.Resource) (*models.Resource,
 		useCharset = lowerCharset
 	}
 
-	d.output["value"] = randomString(d.config.Length, useCharset)
+	d.output["value"] = randomString(int(d.config.Length), useCharset)
 
 	return resource, nil
 }

+ 6 - 26
cli/cmd/preview/update_config_driver.go

@@ -14,32 +14,16 @@ import (
 	"github.com/porter-dev/porter/cli/cmd/config"
 	"github.com/porter-dev/porter/cli/cmd/deploy"
 	"github.com/porter-dev/porter/cli/cmd/deploy/wait"
+	"github.com/porter-dev/porter/internal/integrations/preview"
 	"github.com/porter-dev/porter/internal/templater/utils"
 	"github.com/porter-dev/switchboard/pkg/drivers"
 	"github.com/porter-dev/switchboard/pkg/models"
 )
 
-type UpdateConfigDriverConfig struct {
-	WaitForJob bool
-
-	// If set to true, this does not run an update, it only creates the initial application and job,
-	// skipping subsequent updates
-	OnlyCreate bool
-
-	UpdateConfig struct {
-		Image string
-		Tag   string
-	} `mapstructure:"update_config"`
-
-	EnvGroups []types.EnvGroupMeta `mapstructure:"env_groups"`
-
-	Values map[string]interface{}
-}
-
 type UpdateConfigDriver struct {
-	source      *Source
-	target      *Target
-	config      *UpdateConfigDriverConfig
+	source      *preview.Source
+	target      *preview.Target
+	config      *preview.UpdateConfigDriverConfig
 	lookupTable *map[string]drivers.Driver
 	output      map[string]interface{}
 }
@@ -62,10 +46,6 @@ func NewUpdateConfigDriver(resource *models.Resource, opts *drivers.SharedDriver
 		return nil, err
 	}
 
-	if target.AppName == "" {
-		return nil, fmt.Errorf("target app_name is missing")
-	}
-
 	driver.target = target
 
 	return driver, nil
@@ -225,7 +205,7 @@ func (d *UpdateConfigDriver) Output() (map[string]interface{}, error) {
 	return d.output, nil
 }
 
-func (d *UpdateConfigDriver) getConfig(resource *models.Resource) (*UpdateConfigDriverConfig, error) {
+func (d *UpdateConfigDriver) getConfig(resource *models.Resource) (*preview.UpdateConfigDriverConfig, error) {
 	populatedConf, err := drivers.ConstructConfig(&drivers.ConstructConfigOpts{
 		RawConf:      resource.Config,
 		LookupTable:  *d.lookupTable,
@@ -236,7 +216,7 @@ func (d *UpdateConfigDriver) getConfig(resource *models.Resource) (*UpdateConfig
 		return nil, err
 	}
 
-	config := &UpdateConfigDriverConfig{}
+	config := &preview.UpdateConfigDriverConfig{}
 
 	err = mapstructure.Decode(populatedConf, config)
 

+ 5 - 19
cli/cmd/preview/utils.go

@@ -8,25 +8,11 @@ import (
 
 	"github.com/porter-dev/porter/api/types"
 	"github.com/porter-dev/porter/cli/cmd/config"
+	"github.com/porter-dev/porter/internal/integrations/preview"
 )
 
-type Source struct {
-	Name          string
-	Repo          string
-	Version       string
-	IsApplication bool
-	SourceValues  map[string]interface{}
-}
-
-type Target struct {
-	AppName   string
-	Project   uint
-	Cluster   uint
-	Namespace string
-}
-
-func GetSource(resourceName string, input map[string]interface{}) (*Source, error) {
-	output := &Source{}
+func GetSource(resourceName string, input map[string]interface{}) (*preview.Source, error) {
+	output := &preview.Source{}
 
 	// first read from env vars
 	output.Name = os.Getenv("PORTER_SOURCE_NAME")
@@ -113,8 +99,8 @@ func GetSource(resourceName string, input map[string]interface{}) (*Source, erro
 		resourceName, output.Name, output.Repo)
 }
 
-func GetTarget(resourceName string, input map[string]interface{}) (*Target, error) {
-	output := &Target{}
+func GetTarget(resourceName string, input map[string]interface{}) (*preview.Target, error) {
+	output := &preview.Target{}
 
 	// first read from env vars
 	if projectEnv := os.Getenv("PORTER_PROJECT"); projectEnv != "" {

+ 28 - 7
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 {
@@ -815,15 +836,15 @@ func isPodExited(pod *v1.Pod) bool {
 
 func handlePodAttachError(err error, config *PorterRunSharedConfig, namespace, podName, container string) error {
 	if verbose {
-		color.New(color.FgYellow).Printf("Error: %s\n", err)
+		color.New(color.FgYellow).Fprintf(os.Stderr, "Error: %s\n", err)
 	}
-	color.New(color.FgYellow).Println("Could not open a shell to this container. Container logs:")
+	color.New(color.FgYellow).Fprintln(os.Stderr, "Could not open a shell to this container. Container logs:")
 
 	var writtenBytes int64
 	writtenBytes, _ = pipePodLogsToStdout(config, namespace, podName, container, false)
 
 	if verbose || writtenBytes == 0 {
-		color.New(color.FgYellow).Println("Could not get logs. Pod events:")
+		color.New(color.FgYellow).Fprintln(os.Stderr, "Could not get logs. Pod events:")
 		pipeEventsToStdout(config, namespace, podName, container, false)
 	}
 	return err
@@ -892,7 +913,7 @@ func deletePod(config *PorterRunSharedConfig, name, namespace string) error {
 	)
 
 	if err != nil {
-		color.New(color.FgRed).Printf("Could not delete ephemeral pod: %s\n", err.Error())
+		color.New(color.FgRed).Fprintf(os.Stderr, "Could not delete ephemeral pod: %s\n", err.Error())
 		return err
 	}
 

+ 1 - 1
cli/cmd/utils/close.go

@@ -20,7 +20,7 @@ func closeHandler(closer func() error) {
 			os.Exit(0)
 		}
 
-		color.New(color.FgRed).Printf("shutdown unsuccessful: %s\n", err.Error())
+		color.New(color.FgRed).Fprintf(os.Stderr, "shutdown unsuccessful: %s\n", err.Error())
 		os.Exit(1)
 	}()
 }

Failā izmaiņas netiks attēlotas, jo tās ir par lielu
+ 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",

+ 1 - 0
dashboard/src/App.tsx

@@ -24,6 +24,7 @@ const GlobalStyle = createGlobalStyle`
   * {
     box-sizing: border-box;
     font-family: 'Work Sans', sans-serif;
+    color-scheme: dark;
   }
   
   body {

+ 0 - 4
dashboard/src/assets/Iconly/Bulk/Info Square.svg

@@ -1,4 +0,0 @@
-<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
-<path opacity="0.4" d="M16.34 1.9998H7.67C4.28 1.9998 2 4.3798 2 7.9198V16.0898C2 19.6198 4.28 21.9998 7.67 21.9998H16.34C19.73 21.9998 22 19.6198 22 16.0898V7.9198C22 4.3798 19.73 1.9998 16.34 1.9998Z" fill="white"/>
-<path fill-rule="evenodd" clip-rule="evenodd" d="M11.1247 8.1893C11.1247 8.6713 11.5157 9.0643 11.9947 9.0643C12.4877 9.0643 12.8797 8.6713 12.8797 8.1893C12.8797 7.7073 12.4877 7.3143 12.0047 7.3143C11.5197 7.3143 11.1247 7.7073 11.1247 8.1893ZM12.8697 11.3621C12.8697 10.8801 12.4767 10.4871 11.9947 10.4871C11.5127 10.4871 11.1197 10.8801 11.1197 11.3621V15.7821C11.1197 16.2641 11.5127 16.6571 11.9947 16.6571C12.4767 16.6571 12.8697 16.2641 12.8697 15.7821V11.3621Z" fill="white"/>
-</svg>

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

@@ -0,0 +1,3 @@
+<svg width="16" height="10" viewBox="0 0 16 10" fill="none" xmlns="http://www.w3.org/2000/svg">
+<path d="M15 1.5L8 8.5L1 1.5" stroke="white" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+</svg>

BIN
dashboard/src/assets/azure.png


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

+ 4 - 0
dashboard/src/assets/folder-outline.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.4446 15.7579C21.4446 19.336 19.336 21.4446 15.7579 21.4446H7.97172C4.38443 21.4446 2.27588 19.336 2.27588 15.7579V7.9626C2.27588 4.38444 3.5903 2.27588 7.16846 2.27588H9.16749C9.88576 2.27588 10.5621 2.61406 10.9931 3.18868L11.9059 4.40269C12.3378 4.97618 13.0135 5.31406 13.7315 5.31549H16.5611C20.1484 5.31549 21.472 7.14108 21.472 10.7923L21.4446 15.7579Z" stroke="white" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M7.05893 14.4891H16.6524" 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/last-run.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="M11.3002 12.2513L20.2502 12.2513" stroke="white" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M11.3002 7.25031L3.36317 12.2513L11.3002 17.2523L11.3002 7.25031Z" stroke="white" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+</svg>

+ 4 - 0
dashboard/src/assets/left-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="M4.25 12.2743L19.25 12.2743" stroke="white" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M10.2998 18.2987L4.2498 12.2747L10.2998 6.24969" stroke="white" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+</svg>

+ 6 - 0
dashboard/src/assets/sort.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="M16.8396 20.1642V6.54645" stroke="white" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M20.9172 16.0681L16.8394 20.1648L12.7617 16.0681" stroke="white" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M6.91112 3.83289V17.4507" stroke="white" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M2.83344 7.929L6.91121 3.83234L10.989 7.929" stroke="white" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+</svg>

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

@@ -0,0 +1,6 @@
+<svg width="22" height="22" viewBox="0 0 22 22" fill="none" xmlns="http://www.w3.org/2000/svg">
+<path d="M14.8055 18.9994V3" stroke="white" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M7.19465 3.00064V19" stroke="white" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M3.00065 14.8054L19 14.8054" stroke="white" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M18.9994 7.19465L3.00004 7.19465" 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>

+ 14 - 4
dashboard/src/components/Banner.tsx

@@ -6,11 +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 }) => {
+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>;
     }
@@ -20,6 +26,7 @@ const Banner: React.FC<Props> = ({ type, children }) => {
   return (
     <StyledBanner
       color={type === "error" ? "#ff385d" : type === "warning" && "#f5cb42"}
+      noMargin={noMargin}
     >
       {renderIcon()}
       {children}
@@ -29,16 +36,19 @@ const Banner: React.FC<Props> = ({ type, children }) => {
 
 export default Banner;
 
-const StyledBanner = styled.div<{ color?: string }>`
+const StyledBanner = styled.div<{
+  color?: string;
+  noMargin?: boolean;
+}>`
   height: 40px;
   width: 100%;
-  margin: 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``;

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

@@ -37,7 +37,6 @@ const ButtonWrapper = styled.div`
   overflow: hidden;
   white-space: nowrap;
   text-overflow: ellipsis;
-  box-shadow: 0 5px 8px 0px #00000010;
   cursor: ${(props: { disabled?: boolean }) =>
     props.disabled ? "not-allowed" : "pointer"};
 

+ 126 - 0
dashboard/src/components/CheckboxList.tsx

@@ -0,0 +1,126 @@
+import React, { useEffect } from "react";
+import styled from "styled-components";
+
+type PropsType = {
+  label?: string;
+  options: { disabled?: boolean; value: any; label: string }[];
+  selected: { value: any; label: string }[];
+  setSelected: (x: { value: any; label: string }[]) => void;
+};
+
+const arraysEqual = (a: any, b: any) => {
+  if (a === b) return true;
+  if (a == null || b == null) return false;
+  if (a.length !== b.length) return false;
+
+  // If you don't care about the order of the elements inside
+  // the array, you should sort both arrays here.
+  // Please note that calling sort on an array will modify that array.
+  // you might want to clone your array first.
+
+  for (var i = 0; i < a.length; ++i) {
+    if (a[i] !== b[i]) return false;
+  }
+  return true;
+};
+
+const CheckboxList = ({ label, options, selected, setSelected }: PropsType) => {
+  let onSelectOption = (option: { value: any; label: string }) => {
+    const tmp = [...selected];
+    if (
+      tmp.filter(
+        (e) => e.value === option.value || arraysEqual(e.value, option.value)
+      ).length === 0
+    ) {
+      setSelected([...tmp, option]);
+    } else {
+      tmp.forEach((x, i) => {
+        if (x.value === option.value || arraysEqual(x.value, option.value)) {
+          tmp.splice(i, 1);
+        }
+      });
+      setSelected(tmp);
+    }
+  };
+
+  return (
+    <StyledCheckboxList>
+      {label && <Label>{label}</Label>}
+      {options.map((option: { value: any; label: string }, i: number) => {
+        return (
+          <CheckboxOption
+            isLast={i === options.length - 1}
+            onClick={() => onSelectOption(option)}
+            key={i}
+          >
+            <Checkbox
+              checked={
+                selected.filter(
+                  (e) =>
+                    e.value === option.value ||
+                    arraysEqual(e.value, option.value)
+                ).length > 0
+              }
+            >
+              <i className="material-icons">done</i>
+            </Checkbox>
+            <Text>{option.label}</Text>
+          </CheckboxOption>
+        );
+      })}
+    </StyledCheckboxList>
+  );
+};
+export default CheckboxList;
+
+const Text = styled.div`
+  overflow: hidden;
+  white-space: nowrap;
+  text-overflow: ellipsis;
+  word-break: anywhere;
+  margin-right: 10px;
+`;
+
+const Checkbox = styled.div`
+  width: 14px;
+  height: 14px;
+  min-width: 14px;
+  border: 1px solid #ffffff55;
+  margin: 1px 10px 0px 1px;
+  border-radius: 3px;
+  background: ${(props: { checked: boolean }) =>
+    props.checked ? "#ffffff22" : "#ffffff11"};
+  display: flex;
+  align-items: center;
+  justify-content: center;
+
+  > i {
+    font-size: 12px;
+    padding-left: 0px;
+    display: ${(props: { checked: boolean }) => (props.checked ? "" : "none")};
+  }
+`;
+
+const CheckboxOption = styled.div<{ isLast: boolean }>`
+  width: 100%;
+  height: 35px;
+  padding-left: 10px;
+  display: flex;
+  cursor: pointer;
+  align-items: center;
+  font-size: 13px;
+
+  :hover {
+    background: #ffffff18;
+  }
+`;
+
+const Label = styled.div`
+  color: #ffffff;
+  margin-bottom: 10px;
+`;
+
+const StyledCheckboxList = styled.div`
+  border-radius: 3px;
+  padding: 0;
+`;

+ 73 - 0
dashboard/src/components/CheckboxRow.tsx

@@ -0,0 +1,73 @@
+import React, { Component } from "react";
+import styled from "styled-components";
+
+type PropsType = {
+  label: string;
+  checked: boolean;
+  toggle: () => void;
+  isRequired?: boolean;
+  disabled?: boolean;
+};
+
+type StateType = {};
+
+export default class CheckboxRow extends Component<PropsType, StateType> {
+  render() {
+    return (
+      <StyledCheckboxRow>
+        <CheckboxWrapper
+          disabled={this.props.disabled}
+          onClick={!this.props.disabled ? this.props.toggle : undefined}
+        >
+          <Checkbox checked={this.props.checked}>
+            <i className="material-icons">done</i>
+          </Checkbox>
+          {this.props.label}
+          {this.props.isRequired && <Required>*</Required>}
+        </CheckboxWrapper>
+      </StyledCheckboxRow>
+    );
+  }
+}
+
+const Required = styled.section`
+  margin-left: 8px;
+  color: #fc4976;
+`;
+
+const CheckboxWrapper = styled.div<{ disabled?: boolean }>`
+  display: flex;
+  align-items: center;
+  cursor: ${(props) => (props.disabled ? "not-allowed" : "pointer")};
+  font-size: 13px;
+  :hover {
+    > div {
+      background: #ffffff22;
+    }
+  }
+`;
+
+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 StyledCheckboxRow = styled.div`
+  display: flex;
+  align-items: center;
+  margin-bottom: 15px;
+  margin-top: 20px;
+`;

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

+ 0 - 2
dashboard/src/components/MultiSaveButton.tsx

@@ -258,8 +258,6 @@ const Button = styled.button<ButtonProps>`
   border: 0;
   border-radius: ${(props) => (props.rounded ? "100px" : "5px 0 0 5px")};
   background: ${(props) => (!props.disabled ? props.color : "#aaaabb")};
-  box-shadow: ${(props) =>
-    !props.disabled ? "0 2px 5px 0 #00000030" : "none"};
   cursor: ${(props) => (!props.disabled ? "pointer" : "default")};
   user-select: none;
   :focus {

+ 202 - 0
dashboard/src/components/MultiSelectFilter.tsx

@@ -0,0 +1,202 @@
+import React, { useEffect, useState, useRef } from "react";
+
+import styled from "styled-components";
+import arrow from "assets/arrow-down.svg";
+
+import CheckboxList from "./CheckboxList";
+
+type Props = {
+  name: string;
+  icon?: any;
+  options: { value: any; label: string }[];
+  selected: any[];
+  setSelected: any;
+};
+
+export const MultiSelectFilter: React.FC<Props> = (props) => {
+  const [expanded, setExpanded] = 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)
+    ) {
+      setExpanded(false);
+    }
+  };
+
+  const renderOptions = () => {
+    return props.options.map(
+      (option: { value: any; label: string }, i: number) => {
+        return (
+          <Option key={i} onClick={() => alert("choise")}>
+            {option.label}
+          </Option>
+        );
+      }
+    );
+  };
+
+  const renderDropdown = () => {
+    if (expanded) {
+      return (
+        <DropdownWrapper>
+          <Dropdown ref={wrapperRef}>
+            {props.options.length > 0 ? (
+              <ScrollableWrapper>
+                <CheckboxList
+                  options={props.options}
+                  selected={props.selected}
+                  setSelected={props.setSelected}
+                />
+              </ScrollableWrapper>
+            ) : (
+              <Placeholder>No options found</Placeholder>
+            )}
+          </Dropdown>
+        </DropdownWrapper>
+      );
+    }
+  };
+
+  return (
+    <Relative>
+      <StyledMultiSelectFilter
+        onClick={() => setExpanded(!expanded)}
+        ref={parentRef}
+      >
+        {props.icon && <FilterIcon src={props.icon} />}
+        {props.name}
+        {props.selected.length > 0 && (
+          <FilterCount>{props.selected.length}</FilterCount>
+        )}
+        <DropdownIcon src={arrow} />
+      </StyledMultiSelectFilter>
+      {renderDropdown()}
+    </Relative>
+  );
+};
+
+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;
+  width: 100%;
+  height: 50px;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+`;
+
+const ScrollableWrapper = styled.div`
+  overflow-y: auto;
+  height: 100%;
+  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;
+`;
+
+const DropdownWrapper = styled.div`
+  position: absolute;
+  width: 100%;
+  right: 0;
+  z-index: 1;
+  top: calc(100% + 5px);
+`;
+
+const Dropdown = styled.div`
+  width: 260px;
+  border-radius: 3px;
+  z-index: 999;
+  overflow-y: auto;
+  margin-bottom: 20px;
+  background: #2f3135;
+  padding: 0;
+  border-radius: 5px;
+  border: 1px solid #aaaabb33;
+`;
+
+const DropdownIcon = styled.img`
+  width: 8px;
+  margin-left: 12px;
+`;
+
+const FilterIcon = styled.img`
+  width: 14px;
+  margin-right: 7px;
+`;
+
+const StyledMultiSelectFilter = styled.div`
+  height: 30px;
+  font-size: 13px;
+  position: relative;
+  padding: 10px;
+  background: #26292e;
+  border-radius: 5px;
+  border: 1px solid #aaaabb33;
+  display: flex;
+  align-items: center;
+  margin-right: 10px;
+  cursor: pointer;
+  :hover {
+    background: #ffffff11;
+  }
+`;

+ 2 - 4
dashboard/src/components/ProvisionerStatus.tsx

@@ -464,11 +464,11 @@ export const OperationDetails: React.FunctionComponent<OperationDetailsProps> =
 
     const wsConfig = {
       onopen: () => {
-        console.log(`connected to websocket:`, websocketID);
+        // console.log(`connected to websocket:`, websocketID);
       },
       onmessage: parseOperationWebsocketEvent,
       onclose: () => {
-        console.log(`closing websocket:`, websocketID);
+        // console.log(`closing websocket:`, websocketID);
       },
       onerror: (err: ErrorEvent) => {
         console.log(err);
@@ -951,8 +951,6 @@ const Button = styled.button`
   border: 0;
   border-radius: 5px;
   background: ${(props) => (!props.disabled ? props.color : "#aaaabb")};
-  box-shadow: ${(props) =>
-    !props.disabled ? "0 2px 5px 0 #00000030" : "none"};
   cursor: ${(props) => (!props.disabled ? "pointer" : "default")};
   user-select: none;
   :focus {

+ 220 - 0
dashboard/src/components/RadioFilter.tsx

@@ -0,0 +1,220 @@
+import React, { useEffect, useState, useRef } from "react";
+
+import styled from "styled-components";
+import arrow from "assets/arrow-down.svg";
+
+type Props = {
+  name: string;
+  icon?: any;
+  options: { value: any; label: string }[];
+  selected: any;
+  setSelected: any;
+  noMargin?: boolean;
+  dropdownAlignRight?: boolean;
+};
+
+const RadioFilter: React.FC<Props> = (props) => {
+  const [expanded, setExpanded] = 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)
+    ) {
+      setExpanded(false);
+    }
+  };
+
+  const getLabel = (value: string): any => {
+    let tgt = props.options.find(
+      (element: { value: string; label: string }) => element.value === value
+    );
+    if (tgt) {
+      return tgt.label;
+    }
+  };
+
+  const renderDropdown = () => {
+    let { options } = props;
+    if (expanded) {
+      return (
+        <DropdownWrapper dropdownAlignRight={props.dropdownAlignRight}>
+          <Dropdown ref={wrapperRef}>
+            {options?.length > 0 ? (
+              <ScrollableWrapper>
+                {options.map(
+                  (option: { value: any; label: string }, i: number) => {
+                    return (
+                      <OptionRow
+                        isLast={i === options.length - 1}
+                        onClick={() => {
+                          props.setSelected(option.value);
+                          setExpanded(false);
+                        }}
+                        key={i}
+                        selected={props.selected === option.value}
+                      >
+                        <Text>{option.label}</Text>
+                      </OptionRow>
+                    );
+                  }
+                )}
+              </ScrollableWrapper>
+            ) : (
+              <Placeholder>No options found</Placeholder>
+            )}
+          </Dropdown>
+        </DropdownWrapper>
+      );
+    }
+  };
+
+  return (
+    <Relative>
+      <StyledRadioFilter
+        onClick={() => setExpanded(!expanded)}
+        ref={parentRef}
+        noMargin={props.noMargin}
+      >
+        {props.icon && <FilterIcon src={props.icon} />}
+        <TextAlt>{props.name}</TextAlt>
+        <Bar />
+        <Selected>
+          {props.selected
+            ? props.selected === ""
+              ? "All"
+              : getLabel(props.selected)
+            : ""}
+        </Selected>
+        <DropdownIcon src={arrow} />
+      </StyledRadioFilter>
+      {renderDropdown()}
+    </Relative>
+  );
+};
+
+export default RadioFilter;
+
+const Bar = styled.div`
+  width: 1px;
+  height: calc(18px);
+  background: #494b4f;
+  margin: 0 8px;
+  margin-left: 0;
+`;
+
+const Selected = styled.div`
+  color: #aaaaaa;
+  overflow: hidden;
+  white-space: nowrap;
+  text-overflow: ellipsis;
+  max-width: 120px;
+`;
+
+const Text = styled.div`
+  overflow: hidden;
+  white-space: nowrap;
+  text-overflow: ellipsis;
+  word-break: anywhere;
+  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;
+  padding-left: 10px;
+  display: flex;
+  cursor: pointer;
+  align-items: center;
+  font-size: 13px;
+  background: ${(props) => (props.selected ? "#ffffff11" : "")};
+
+  :hover {
+    background: #ffffff18;
+  }
+`;
+
+const Placeholder = styled.div`
+  color: #aaaabb88;
+  font-size: 12px;
+  width: 100%;
+  height: 50px;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+`;
+
+const ScrollableWrapper = styled.div`
+  overflow-y: auto;
+  max-height: 350px;
+`;
+
+const Relative = styled.div`
+  position: relative;
+`;
+
+const DropdownWrapper = styled.div<{ dropdownAlignRight?: boolean }>`
+  position: absolute;
+  left: ${(props) => (props.dropdownAlignRight ? "" : "0")};
+  right: ${(props) => (props.dropdownAlignRight ? "0" : "")};
+  z-index: 1;
+  top: calc(100% + 5px);
+`;
+
+const Dropdown = styled.div`
+  width: 260px;
+  border-radius: 3px;
+  z-index: 999;
+  overflow-y: auto;
+  background: #2f3135;
+  padding: 0;
+  border-radius: 5px;
+  border: 1px solid #aaaabb33;
+`;
+
+const DropdownIcon = styled.img`
+  width: 8px;
+  margin-left: 12px;
+`;
+
+const FilterIcon = styled.img`
+  width: 14px;
+  margin-right: 9px;
+`;
+
+const StyledRadioFilter = styled.div<{ noMargin?: boolean }>`
+  height: 30px;
+  font-size: 13px;
+  position: relative;
+  padding: 10px;
+  background: #26292e;
+  border-radius: 5px;
+  display: flex;
+  align-items: center;
+  margin-right: ${(props) => (props.noMargin ? "" : "10px")};
+  cursor: pointer;
+  border: 1px solid #494b4f;
+  :hover {
+    border: 1px solid #7a7b80;
+  }
+`;

+ 0 - 2
dashboard/src/components/SaveButton.tsx

@@ -195,8 +195,6 @@ const Button = styled.button<{
   border: 0;
   border-radius: ${(props) => (props.rounded ? "100px" : "5px")};
   background: ${(props) => (!props.disabled ? props.color : "#aaaabb")};
-  box-shadow: ${(props) =>
-    !props.disabled ? "0 2px 5px 0 #00000030" : "none"};
   cursor: ${(props) => (!props.disabled ? "pointer" : "default")};
   user-select: none;
   :focus {

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

@@ -98,7 +98,7 @@ export default class Selector extends Component<SelectorPropsType, StateType> {
           }}
         >
           <Plus>+</Plus>
-          Add Namespace
+          Add namespace
         </NewOption>
       );
     }

+ 3 - 3
dashboard/src/components/Table.tsx

@@ -365,7 +365,7 @@ const SearchInput = styled.input`
   width: 100%;
   color: white;
   padding: 0;
-  height: 20px;
+  height: 21px;
 `;
 
 const SearchRow = styled.div`
@@ -376,7 +376,7 @@ const SearchRow = styled.div`
   border-radius: 4px;
   user-select: none;
   align-items: center;
-  padding: 10px 0px;
+  padding: 7px 0px;
   min-width: 300px;
   max-width: min-content;
   background: #ffffff11;
@@ -386,7 +386,7 @@ const SearchRow = styled.div`
     height: 18px;
     margin-left: 12px;
     margin-right: 12px;
-    font-size: 20px;
+    font-size: 18px;
   }
 `;
 

+ 4 - 3
dashboard/src/components/TitleSection.tsx

@@ -65,20 +65,21 @@ const StyledTitleSection = styled.div`
   margin-bottom: 15px;
   display: flex;
   align-items: center;
+  height: 35px;
 `;
 
 const Icon = styled.img<{ width: string }>`
-  width: ${(props) => props.width || "28px"};
+  width: ${(props) => props.width || "25px"};
   margin-right: 16px;
 `;
 
 const MaterialIcon = styled.span<{ width: string }>`
-  width: ${(props) => props.width || "28px"};
+  width: ${(props) => props.width || "25px"};
   margin-right: 16px;
 `;
 
 const StyledTitle = styled.div<{ capitalize: boolean }>`
-  font-size: 24px;
+  font-size: 21px;
   font-weight: 600;
   user-select: text;
   text-transform: ${(props) => (props.capitalize ? "capitalize" : "")};

Daži faili netika attēloti, jo izmaiņu fails ir pārāk liels