Просмотр исходного кода

Merge pull request #1286 from porter-dev/staging

Improved websocket handling + minor fixes -> production
abelanger5 4 лет назад
Родитель
Сommit
1f8feb4f89
67 измененных файлов с 869 добавлено и 363 удалено
  1. 3 2
      .github/workflows/dev.yaml
  2. 1 0
      .github/workflows/production.yaml
  3. 1 0
      .github/workflows/staging.yaml
  4. 13 0
      .github/workflows/test-backend.yml
  5. 1 1
      api/server/authn/handler.go
  6. 2 2
      api/server/authz/cluster.go
  7. 2 2
      api/server/authz/git_installation.go
  8. 2 2
      api/server/authz/helm_repo.go
  9. 2 2
      api/server/authz/infra.go
  10. 2 2
      api/server/authz/invite.go
  11. 3 2
      api/server/authz/policy.go
  12. 11 1
      api/server/authz/project.go
  13. 2 2
      api/server/authz/registry.go
  14. 3 3
      api/server/authz/release.go
  15. 3 8
      api/server/handlers/cluster/stream_helm_release.go
  16. 3 8
      api/server/handlers/cluster/stream_status.go
  17. 6 1
      api/server/handlers/handler.go
  18. 11 0
      api/server/handlers/infra/delete.go
  19. 3 8
      api/server/handlers/infra/stream_logs.go
  20. 17 30
      api/server/handlers/invite/accept.go
  21. 10 8
      api/server/handlers/namespace/stream_pod_logs.go
  22. 1 0
      api/server/handlers/provision/provision_docr.go
  23. 1 0
      api/server/handlers/provision/provision_doks.go
  24. 1 0
      api/server/handlers/provision/provision_ecr.go
  25. 1 0
      api/server/handlers/provision/provision_eks.go
  26. 1 0
      api/server/handlers/provision/provision_gcr.go
  27. 1 0
      api/server/handlers/provision/provision_gke.go
  28. 2 0
      api/server/router/cluster.go
  29. 1 0
      api/server/router/infra.go
  30. 2 0
      api/server/router/invite.go
  31. 11 0
      api/server/router/middleware/content_type_json.go
  32. 31 0
      api/server/router/middleware/panic.go
  33. 59 0
      api/server/router/middleware/request_logger.go
  34. 45 0
      api/server/router/middleware/websocket.go
  35. 1 0
      api/server/router/namespace.go
  36. 11 80
      api/server/router/router.go
  37. 16 13
      api/server/shared/apierrors/errors.go
  38. 1 1
      api/server/shared/apitest/request.go
  39. 1 1
      api/server/shared/config/config.go
  40. 9 6
      api/server/shared/config/loader/loader.go
  41. 2 2
      api/server/shared/reader.go
  42. 78 0
      api/server/shared/websocket/response_writer.go
  43. 34 0
      api/server/shared/websocket/upgrader.go
  44. 3 3
      api/server/shared/writer.go
  45. 5 0
      api/types/request.go
  46. 82 50
      dashboard/package-lock.json
  47. 3 2
      dashboard/package.json
  48. 1 1
      dashboard/src/App.tsx
  49. 2 2
      dashboard/src/components/UnexpectedErrorPage.tsx
  50. 4 1
      dashboard/src/index.tsx
  51. 4 1
      dashboard/src/main/MainWrapper.tsx
  52. 1 3
      dashboard/src/main/home/cluster-dashboard/chart/Chart.tsx
  53. 7 5
      dashboard/src/main/home/cluster-dashboard/expanded-chart/ExpandedChartWrapper.tsx
  54. 0 2
      dashboard/src/main/home/launch/launch-flow/SettingsPage.tsx
  55. 2 2
      dashboard/src/main/home/provisioner/AWSFormSection.tsx
  56. 0 53
      dashboard/src/shared/PorterErrorBoundary.tsx
  57. 33 0
      dashboard/src/shared/error_handling/MainWrapperErrorBoundary.tsx
  58. 84 0
      dashboard/src/shared/error_handling/PorterErrorBoundary.tsx
  59. 3 2
      dashboard/src/shared/error_handling/sentry/setup.ts
  60. 9 0
      dashboard/src/shared/error_handling/stack_trace_utils.ts
  61. 35 0
      dashboard/src/shared/error_handling/window_error_handling.ts
  62. 1 0
      dashboard/webpack.config.js
  63. 4 0
      internal/analytics/track_events.go
  64. 44 0
      internal/analytics/tracks.go
  65. 117 39
      internal/kubernetes/agent.go
  66. 10 0
      internal/kubernetes/provisioner/global_stream.go
  67. 4 10
      internal/kubernetes/provisioner/resource_stream.go

+ 3 - 2
.github/workflows/dev.yaml

@@ -28,7 +28,7 @@ jobs:
       - name: Write Dashboard Environment Variables
         run: |
           cat >./dashboard/.env <<EOL
-          NODE_ENV=production
+          NODE_ENV=development
           API_SERVER=dashboard.dev.getporter.dev
           DISCORD_KEY=${{secrets.DISCORD_KEY}}
           DISCORD_CID=${{secrets.DISCORD_CID}}
@@ -38,6 +38,7 @@ jobs:
           ADDON_CHART_REPO_URL=https://chart-addons.dev.getporter.dev
           ENABLE_SENTRY=true
           SENTRY_DSN=${{secrets.SENTRY_DSN}}
+          SENTRY_ENV=development
           EOL
       - name: Build
         run: |
@@ -49,4 +50,4 @@ jobs:
         run: |
           aws eks --region ${{ secrets.AWS_REGION }} update-kubeconfig --name dev
             
-          kubectl rollout restart deployment/porter
+          kubectl rollout restart deployment/porter

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

@@ -38,6 +38,7 @@ jobs:
           ADDON_CHART_REPO_URL=https://chart-addons.getporter.dev
           ENABLE_SENTRY=true
           SENTRY_DSN=${{secrets.SENTRY_DSN}}
+          SENTRY_ENV=production
           EOL
       - name: Build
         run: |

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

@@ -38,6 +38,7 @@ jobs:
           ADDON_CHART_REPO_URL=https://chart-addons.staging.getporter.dev
           ENABLE_SENTRY=true
           SENTRY_DSN=${{secrets.SENTRY_DSN}}
+          SENTRY_ENV=staging
           EOL
       - name: Build
         run: |

+ 13 - 0
.github/workflows/test-backend.yml

@@ -0,0 +1,13 @@
+name: Backend CI
+on:
+  - pull_request
+jobs:
+  backend-tests:
+    name: Run Go tests
+    runs-on: ubuntu-latest
+    steps:
+    - uses: actions/checkout@v2
+    - uses: actions/setup-go@v2.1.4
+      with:
+        go-version: '^1.15.1'
+    - run: go test ./...

+ 1 - 1
api/server/authn/handler.go

@@ -153,7 +153,7 @@ func (authn *AuthN) nextWithUserID(w http.ResponseWriter, r *http.Request, userI
 func (authn *AuthN) sendForbiddenError(err error, w http.ResponseWriter, r *http.Request) {
 	reqErr := apierrors.NewErrForbidden(err)
 
-	apierrors.HandleAPIError(authn.config, w, r, reqErr)
+	apierrors.HandleAPIError(authn.config, w, r, reqErr, true)
 }
 
 var errInvalidToken = fmt.Errorf("authorization header exists, but token is not valid")

+ 2 - 2
api/server/authz/cluster.go

@@ -52,9 +52,9 @@ func (p *ClusterScopedMiddleware) ServeHTTP(w http.ResponseWriter, r *http.Reque
 		if err == gorm.ErrRecordNotFound {
 			apierrors.HandleAPIError(p.config, w, r, apierrors.NewErrForbidden(
 				fmt.Errorf("cluster with id %d not found in project %d", clusterID, proj.ID),
-			))
+			), true)
 		} else {
-			apierrors.HandleAPIError(p.config, w, r, apierrors.NewErrInternal(err))
+			apierrors.HandleAPIError(p.config, w, r, apierrors.NewErrInternal(err), true)
 		}
 
 		return

+ 2 - 2
api/server/authz/git_installation.go

@@ -46,12 +46,12 @@ func (p *GitInstallationScopedMiddleware) ServeHTTP(w http.ResponseWriter, r *ht
 	gitInstallation, err := p.config.Repo.GithubAppInstallation().ReadGithubAppInstallationByInstallationID(gitInstallationID)
 
 	if err != nil {
-		apierrors.HandleAPIError(p.config, w, r, apierrors.NewErrInternal(err))
+		apierrors.HandleAPIError(p.config, w, r, apierrors.NewErrInternal(err), true)
 		return
 	}
 
 	if err := p.doesUserHaveGitInstallationAccess(user.GithubAppIntegrationID, gitInstallationID); err != nil {
-		apierrors.HandleAPIError(p.config, w, r, apierrors.NewErrInternal(err))
+		apierrors.HandleAPIError(p.config, w, r, apierrors.NewErrInternal(err), true)
 		return
 	}
 

+ 2 - 2
api/server/authz/helm_repo.go

@@ -45,9 +45,9 @@ func (p *HelmRepoScopedMiddleware) ServeHTTP(w http.ResponseWriter, r *http.Requ
 		if err == gorm.ErrRecordNotFound {
 			apierrors.HandleAPIError(p.config, w, r, apierrors.NewErrForbidden(
 				fmt.Errorf("helm repo with id %d not found in project %d", helmRepoID, proj.ID),
-			))
+			), true)
 		} else {
-			apierrors.HandleAPIError(p.config, w, r, apierrors.NewErrInternal(err))
+			apierrors.HandleAPIError(p.config, w, r, apierrors.NewErrInternal(err), true)
 		}
 
 		return

+ 2 - 2
api/server/authz/infra.go

@@ -45,9 +45,9 @@ func (p *InfraScopedMiddleware) ServeHTTP(w http.ResponseWriter, r *http.Request
 		if err == gorm.ErrRecordNotFound {
 			apierrors.HandleAPIError(p.config, w, r, apierrors.NewErrForbidden(
 				fmt.Errorf("infra with id %d not found in project %d", infraID, proj.ID),
-			))
+			), true)
 		} else {
-			apierrors.HandleAPIError(p.config, w, r, apierrors.NewErrInternal(err))
+			apierrors.HandleAPIError(p.config, w, r, apierrors.NewErrInternal(err), true)
 		}
 
 		return

+ 2 - 2
api/server/authz/invite.go

@@ -45,9 +45,9 @@ func (p *InviteScopedMiddleware) ServeHTTP(w http.ResponseWriter, r *http.Reques
 		if err == gorm.ErrRecordNotFound {
 			apierrors.HandleAPIError(p.config, w, r, apierrors.NewErrForbidden(
 				fmt.Errorf("invite with id %d not found in project %d", inviteID, proj.ID),
-			))
+			), true)
 		} else {
-			apierrors.HandleAPIError(p.config, w, r, apierrors.NewErrInternal(err))
+			apierrors.HandleAPIError(p.config, w, r, apierrors.NewErrInternal(err), true)
 		}
 
 		return

+ 3 - 2
api/server/authz/policy.go

@@ -43,7 +43,7 @@ func (h *PolicyHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
 	reqScopes, reqErr := getRequestActionForEndpoint(r, h.endpointMeta)
 
 	if reqErr != nil {
-		apierrors.HandleAPIError(h.config, w, r, reqErr)
+		apierrors.HandleAPIError(h.config, w, r, reqErr, true)
 		return
 	}
 
@@ -54,7 +54,7 @@ func (h *PolicyHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
 	policyDocs, reqErr := h.loader.LoadPolicyDocuments(user.ID, projID)
 
 	if reqErr != nil {
-		apierrors.HandleAPIError(h.config, w, r, reqErr)
+		apierrors.HandleAPIError(h.config, w, r, reqErr, true)
 		return
 	}
 
@@ -67,6 +67,7 @@ func (h *PolicyHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
 			w,
 			r,
 			apierrors.NewErrForbidden(fmt.Errorf("policy forbids action for user %d in project %d", user.ID, projID)),
+			true,
 		)
 
 		return

+ 11 - 1
api/server/authz/project.go

@@ -2,12 +2,14 @@ package authz
 
 import (
 	"context"
+	"fmt"
 	"net/http"
 
 	"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"
+	"gorm.io/gorm"
 )
 
 type ProjectScopedFactory struct {
@@ -38,7 +40,15 @@ func (p *ProjectScopedMiddleware) ServeHTTP(w http.ResponseWriter, r *http.Reque
 	project, err := p.config.Repo.Project().ReadProject(projID)
 
 	if err != nil {
-		apierrors.HandleAPIError(p.config, w, r, apierrors.NewErrInternal(err))
+		if err == gorm.ErrRecordNotFound {
+			apierrors.HandleAPIError(p.config, w, r, apierrors.NewErrForbidden(
+				fmt.Errorf("project not found with id %d", projID),
+			), true)
+
+			return
+		}
+
+		apierrors.HandleAPIError(p.config, w, r, apierrors.NewErrInternal(err), true)
 		return
 	}
 

+ 2 - 2
api/server/authz/registry.go

@@ -45,9 +45,9 @@ func (p *RegistryScopedMiddleware) ServeHTTP(w http.ResponseWriter, r *http.Requ
 		if err == gorm.ErrRecordNotFound {
 			apierrors.HandleAPIError(p.config, w, r, apierrors.NewErrForbidden(
 				fmt.Errorf("registry with id %d not found in project %d", registryID, proj.ID),
-			))
+			), true)
 		} else {
-			apierrors.HandleAPIError(p.config, w, r, apierrors.NewErrInternal(err))
+			apierrors.HandleAPIError(p.config, w, r, apierrors.NewErrInternal(err), true)
 		}
 
 		return

+ 3 - 3
api/server/authz/release.go

@@ -40,7 +40,7 @@ func (p *ReleaseScopedMiddleware) ServeHTTP(w http.ResponseWriter, r *http.Reque
 	helmAgent, err := p.agentGetter.GetHelmAgent(r, cluster, "")
 
 	if err != nil {
-		apierrors.HandleAPIError(p.config, w, r, apierrors.NewErrInternal(err))
+		apierrors.HandleAPIError(p.config, w, r, apierrors.NewErrInternal(err), true)
 		return
 	}
 
@@ -60,9 +60,9 @@ func (p *ReleaseScopedMiddleware) ServeHTTP(w http.ResponseWriter, r *http.Reque
 			apierrors.HandleAPIError(p.config, w, r, apierrors.NewErrPassThroughToClient(
 				fmt.Errorf("release not found"),
 				http.StatusNotFound,
-			))
+			), true)
 		} else {
-			apierrors.HandleAPIError(p.config, w, r, apierrors.NewErrInternal(err))
+			apierrors.HandleAPIError(p.config, w, r, apierrors.NewErrInternal(err), true)
 		}
 
 		return

+ 3 - 8
api/server/handlers/cluster/stream_helm_release.go

@@ -8,6 +8,7 @@ import (
 	"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"
 )
@@ -29,13 +30,7 @@ func NewStreamHelmReleaseHandler(
 }
 
 func (c *StreamHelmReleaseHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
-	conn, err := c.Config().WSUpgrader.Upgrade(w, r, nil)
-
-	if err != nil {
-		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
-		return
-	}
-
+	safeRW := r.Context().Value(types.RequestCtxWebsocketKey).(*websocket.WebsocketSafeReadWriter)
 	request := &types.StreamHelmReleaseRequest{}
 
 	if ok := c.DecodeAndValidate(w, r, request); !ok {
@@ -51,7 +46,7 @@ func (c *StreamHelmReleaseHandler) ServeHTTP(w http.ResponseWriter, r *http.Requ
 		return
 	}
 
-	err = agent.StreamHelmReleases(conn, request.Namespace, request.Charts, request.Selectors)
+	err = agent.StreamHelmReleases(request.Namespace, request.Charts, request.Selectors, safeRW)
 
 	if err != nil {
 		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))

+ 3 - 8
api/server/handlers/cluster/stream_status.go

@@ -9,6 +9,7 @@ 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/server/shared/requestutils"
+	"github.com/porter-dev/porter/api/server/shared/websocket"
 	"github.com/porter-dev/porter/api/types"
 	"github.com/porter-dev/porter/internal/models"
 )
@@ -30,13 +31,7 @@ func NewStreamStatusHandler(
 }
 
 func (c *StreamStatusHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
-	conn, err := c.Config().WSUpgrader.Upgrade(w, r, nil)
-
-	if err != nil {
-		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
-		return
-	}
-
+	safeRW := r.Context().Value(types.RequestCtxWebsocketKey).(*websocket.WebsocketSafeReadWriter)
 	request := &types.StreamStatusRequest{}
 
 	if ok := c.DecodeAndValidate(w, r, request); !ok {
@@ -54,7 +49,7 @@ func (c *StreamStatusHandler) ServeHTTP(w http.ResponseWriter, r *http.Request)
 
 	kind, _ := requestutils.GetURLParamString(r, types.URLParamKind)
 
-	err = agent.StreamControllerStatus(conn, kind, request.Selectors)
+	err = agent.StreamControllerStatus(kind, request.Selectors, safeRW)
 
 	if err != nil {
 		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))

+ 6 - 1
api/server/handlers/handler.go

@@ -16,6 +16,7 @@ type PorterHandler interface {
 	Config() *config.Config
 	Repo() repository.Repository
 	HandleAPIError(w http.ResponseWriter, r *http.Request, err apierrors.RequestError)
+	HandleAPIErrorNoWrite(w http.ResponseWriter, r *http.Request, err apierrors.RequestError)
 	PopulateOAuthSession(w http.ResponseWriter, r *http.Request, state string, isProject bool) error
 }
 
@@ -57,7 +58,11 @@ func (d *DefaultPorterHandler) Repo() repository.Repository {
 }
 
 func (d *DefaultPorterHandler) HandleAPIError(w http.ResponseWriter, r *http.Request, err apierrors.RequestError) {
-	apierrors.HandleAPIError(d.Config(), w, r, err)
+	apierrors.HandleAPIError(d.Config(), w, r, err, true)
+}
+
+func (d *DefaultPorterHandler) HandleAPIErrorNoWrite(w http.ResponseWriter, r *http.Request, err apierrors.RequestError) {
+	apierrors.HandleAPIError(d.Config(), w, r, err, false)
 }
 
 func (d *DefaultPorterHandler) WriteResult(w http.ResponseWriter, r *http.Request, v interface{}) {

+ 11 - 0
api/server/handlers/infra/delete.go

@@ -8,6 +8,7 @@ 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/analytics"
 	"github.com/porter-dev/porter/internal/kubernetes"
 	"github.com/porter-dev/porter/internal/kubernetes/provisioner"
 	"github.com/porter-dev/porter/internal/models"
@@ -37,6 +38,16 @@ func (c *InfraDeleteHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
 		return
 	}
 
+	if infra.Kind == types.InfraDOKS || infra.Kind == types.InfraGKE || infra.Kind == types.InfraEKS {
+		c.Config().AnalyticsClient.Track(analytics.ClusterDestroyingStartTrack(
+			&analytics.ClusterDestroyingStartTrackOpts{
+				ClusterScopedTrackOpts: analytics.GetClusterScopedTrackOpts(infra.CreatedByUserID, infra.ProjectID, 0),
+				ClusterType:            infra.Kind,
+				InfraID:                infra.ID,
+			},
+		))
+	}
+
 	infra.Status = types.StatusDestroying
 	infra, err := c.Repo().Infra().UpdateInfra(infra)
 

+ 3 - 8
api/server/handlers/infra/stream_logs.go

@@ -7,6 +7,7 @@ import (
 	"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/adapter"
 	"github.com/porter-dev/porter/internal/kubernetes/provisioner"
@@ -27,13 +28,7 @@ func NewInfraStreamLogsHandler(
 }
 
 func (c *InfraStreamLogsHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
-	conn, err := c.Config().WSUpgrader.Upgrade(w, r, nil)
-
-	if err != nil {
-		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
-		return
-	}
-
+	safeRW := r.Context().Value(types.RequestCtxWebsocketKey).(*websocket.WebsocketSafeReadWriter)
 	infra, _ := r.Context().Value(types.InfraScope).(*models.Infra)
 
 	client, err := adapter.NewRedisClient(c.Config().RedisConf)
@@ -43,7 +38,7 @@ func (c *InfraStreamLogsHandler) ServeHTTP(w http.ResponseWriter, r *http.Reques
 		return
 	}
 
-	err = provisioner.ResourceStream(client, infra.GetUniqueName(), conn)
+	err = provisioner.ResourceStream(client, infra.GetUniqueName(), safeRW)
 
 	if err != nil {
 		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))

+ 17 - 30
api/server/handlers/invite/accept.go

@@ -1,12 +1,10 @@
 package invite
 
 import (
+	"errors"
 	"fmt"
 	"net/http"
 	"net/url"
-	"strconv"
-
-	"github.com/go-chi/chi"
 
 	"github.com/porter-dev/porter/api/server/handlers"
 	"github.com/porter-dev/porter/api/server/shared/apierrors"
@@ -14,6 +12,7 @@ import (
 	"github.com/porter-dev/porter/api/server/shared/requestutils"
 	"github.com/porter-dev/porter/api/types"
 	"github.com/porter-dev/porter/internal/models"
+	"gorm.io/gorm"
 )
 
 type InviteAcceptHandler struct {
@@ -29,33 +28,28 @@ func NewInviteAcceptHandler(
 }
 
 func (c *InviteAcceptHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	user, _ := r.Context().Value(types.UserScope).(*models.User)
+	projectID, _ := requestutils.GetURLParamUint(r, types.URLParamProjectID)
 	token, _ := requestutils.GetURLParamString(r, types.URLParamInviteToken)
 
-	session, err := c.Config().Store.Get(r, c.Config().ServerConf.CookieName)
-
-	if err != nil {
-		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
-	}
-
-	userID, _ := session.Values["user_id"].(uint)
-
-	user, err := c.Repo().User().ReadUser(userID)
+	proj, err := c.Repo().Project().ReadProject(projectID)
 
 	if err != nil {
-		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
-		return
-	}
+		vals := url.Values{}
 
-	projectID, err := strconv.ParseUint(chi.URLParam(r, "project_id"), 0, 64)
+		if errors.Is(err, gorm.ErrRecordNotFound) {
+			vals.Add("error", "Invalid invite token")
+		} else {
+			vals.Add("error", "Unknown error")
+		}
 
-	if err != nil || projectID == 0 {
-		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		http.Redirect(w, r, fmt.Sprintf("/dashboard?%s", vals.Encode()), 302)
 		return
 	}
 
 	invite, err := c.Repo().Invite().ReadInviteByToken(token)
 
-	if err != nil || invite.ProjectID != uint(projectID) {
+	if err != nil || invite.ProjectID != proj.ID {
 		vals := url.Values{}
 		vals.Add("error", "Invalid invite token")
 		http.Redirect(w, r, fmt.Sprintf("/dashboard?%s", vals.Encode()), 302)
@@ -87,17 +81,10 @@ func (c *InviteAcceptHandler) ServeHTTP(w http.ResponseWriter, r *http.Request)
 		kind = models.RoleDeveloper
 	}
 
-	project, err := c.Repo().Project().ReadProject(uint(projectID))
-
-	if err != nil {
-		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
-		return
-	}
-
-	if _, err = c.Repo().Project().CreateProjectRole(project, &models.Role{
+	if _, err = c.Repo().Project().CreateProjectRole(proj, &models.Role{
 		Role: types.Role{
-			UserID:    userID,
-			ProjectID: project.ID,
+			UserID:    user.ID,
+			ProjectID: proj.ID,
 			Kind:      types.RoleKind(kind),
 		},
 	}); err != nil {
@@ -106,7 +93,7 @@ func (c *InviteAcceptHandler) ServeHTTP(w http.ResponseWriter, r *http.Request)
 	}
 
 	// update the invite
-	invite.UserID = userID
+	invite.UserID = user.ID
 
 	if _, err = c.Repo().Invite().UpdateInvite(invite); err != nil {
 		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))

+ 10 - 8
api/server/handlers/namespace/stream_pod_logs.go

@@ -11,6 +11,7 @@ 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/server/shared/requestutils"
+	"github.com/porter-dev/porter/api/server/shared/websocket"
 	"github.com/porter-dev/porter/api/types"
 	"github.com/porter-dev/porter/internal/kubernetes"
 	"github.com/porter-dev/porter/internal/models"
@@ -33,13 +34,7 @@ func NewStreamPodLogsHandler(
 }
 
 func (c *StreamPodLogsHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
-	conn, err := c.Config().WSUpgrader.Upgrade(w, r, nil)
-
-	if err != nil {
-		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
-		return
-	}
-
+	safeRW := r.Context().Value(types.RequestCtxWebsocketKey).(*websocket.WebsocketSafeReadWriter)
 	namespace := r.Context().Value(types.NamespaceScope).(string)
 	name, _ := requestutils.GetURLParamString(r, types.URLParamPodName)
 
@@ -52,7 +47,7 @@ func (c *StreamPodLogsHandler) ServeHTTP(w http.ResponseWriter, r *http.Request)
 		return
 	}
 
-	err = agent.GetPodLogs(namespace, name, conn)
+	err = agent.GetPodLogs(namespace, name, safeRW)
 
 	if targetErr := kubernetes.IsNotFoundError; errors.Is(err, targetErr) {
 		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(
@@ -60,6 +55,13 @@ func (c *StreamPodLogsHandler) ServeHTTP(w http.ResponseWriter, r *http.Request)
 			http.StatusNotFound,
 		))
 
+		return
+	} else if brErr := (kubernetes.BadRequestError{}); errors.As(err, &targetErr) {
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(
+			&brErr,
+			http.StatusBadRequest,
+		))
+
 		return
 	} else if err != nil {
 		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))

+ 1 - 0
api/server/handlers/provision/provision_docr.go

@@ -69,6 +69,7 @@ func (c *ProvisionDOCRHandler) ServeHTTP(w http.ResponseWriter, r *http.Request)
 		Suffix:          suffix,
 		Status:          types.StatusCreating,
 		DOIntegrationID: request.DOIntegrationID,
+		CreatedByUserID: user.ID,
 	}
 
 	// handle write to the database

+ 1 - 0
api/server/handlers/provision/provision_doks.go

@@ -69,6 +69,7 @@ func (c *ProvisionDOKSHandler) ServeHTTP(w http.ResponseWriter, r *http.Request)
 		Suffix:          suffix,
 		Status:          types.StatusCreating,
 		DOIntegrationID: request.DOIntegrationID,
+		CreatedByUserID: user.ID,
 	}
 
 	// handle write to the database

+ 1 - 0
api/server/handlers/provision/provision_ecr.go

@@ -69,6 +69,7 @@ func (c *ProvisionECRHandler) ServeHTTP(w http.ResponseWriter, r *http.Request)
 		Suffix:           suffix,
 		Status:           types.StatusCreating,
 		AWSIntegrationID: request.AWSIntegrationID,
+		CreatedByUserID:  user.ID,
 	}
 
 	// handle write to the database

+ 1 - 0
api/server/handlers/provision/provision_eks.go

@@ -69,6 +69,7 @@ func (c *ProvisionEKSHandler) ServeHTTP(w http.ResponseWriter, r *http.Request)
 		Suffix:           suffix,
 		Status:           types.StatusCreating,
 		AWSIntegrationID: request.AWSIntegrationID,
+		CreatedByUserID:  user.ID,
 	}
 
 	// handle write to the database

+ 1 - 0
api/server/handlers/provision/provision_gcr.go

@@ -69,6 +69,7 @@ func (c *ProvisionGCRHandler) ServeHTTP(w http.ResponseWriter, r *http.Request)
 		Suffix:           suffix,
 		Status:           types.StatusCreating,
 		GCPIntegrationID: request.GCPIntegrationID,
+		CreatedByUserID:  user.ID,
 	}
 
 	// handle write to the database

+ 1 - 0
api/server/handlers/provision/provision_gke.go

@@ -69,6 +69,7 @@ func (c *ProvisionGKEHandler) ServeHTTP(w http.ResponseWriter, r *http.Request)
 		Suffix:           suffix,
 		Status:           types.StatusCreating,
 		GCPIntegrationID: request.GCPIntegrationID,
+		CreatedByUserID:  user.ID,
 	}
 
 	// handle write to the database

+ 2 - 0
api/server/router/cluster.go

@@ -518,6 +518,7 @@ func getClusterRoutes(
 				types.ProjectScope,
 				types.ClusterScope,
 			},
+			IsWebsocket: true,
 		},
 	)
 
@@ -551,6 +552,7 @@ func getClusterRoutes(
 				types.ProjectScope,
 				types.ClusterScope,
 			},
+			IsWebsocket: true,
 		},
 	)
 

+ 1 - 0
api/server/router/infra.go

@@ -121,6 +121,7 @@ func getInfraRoutes(
 				types.ProjectScope,
 				types.InfraScope,
 			},
+			IsWebsocket: true,
 		},
 	)
 

+ 2 - 0
api/server/router/invite.go

@@ -116,6 +116,8 @@ func getInviteRoutes(
 				Parent:       basePath,
 				RelativePath: "/invites/{token}",
 			},
+			// only user scope is needed here. adding the project scope will prevent the user
+			// from joining the project, since they don't have a role in the project yet.
 			Scopes: []types.PermissionScope{
 				types.UserScope,
 			},

+ 11 - 0
api/server/router/middleware/content_type_json.go

@@ -0,0 +1,11 @@
+package middleware
+
+import "net/http"
+
+// ContentTypeJSON sets the content type for requests to application/json
+func ContentTypeJSON(next http.Handler) http.Handler {
+	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		w.Header().Set("Content-Type", "application/json;charset=utf8")
+		next.ServeHTTP(w, r)
+	})
+}

+ 31 - 0
api/server/router/middleware/panic.go

@@ -0,0 +1,31 @@
+package middleware
+
+import (
+	"fmt"
+	"net/http"
+
+	"github.com/porter-dev/porter/api/server/shared/apierrors"
+	"github.com/porter-dev/porter/api/server/shared/config"
+)
+
+type PanicMiddleware struct {
+	config *config.Config
+}
+
+func NewPanicMiddleware(config *config.Config) *PanicMiddleware {
+	return &PanicMiddleware{config}
+}
+
+func (pmw *PanicMiddleware) Middleware(next http.Handler) http.Handler {
+	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		defer func() {
+			err := recover()
+
+			if err != nil {
+				apierrors.HandleAPIError(pmw.config, w, r, apierrors.NewErrInternal(fmt.Errorf("%v", err)), true)
+			}
+		}()
+
+		next.ServeHTTP(w, r)
+	})
+}

+ 59 - 0
api/server/router/middleware/request_logger.go

@@ -0,0 +1,59 @@
+package middleware
+
+import (
+	"bufio"
+	"errors"
+	"net"
+	"net/http"
+	"time"
+
+	"github.com/porter-dev/porter/internal/logger"
+)
+
+type requestLoggerResponseWriter struct {
+	http.ResponseWriter
+	statusCode int
+}
+
+func newRequestLoggerResponseWriter(w http.ResponseWriter) *requestLoggerResponseWriter {
+	return &requestLoggerResponseWriter{w, http.StatusOK}
+}
+
+func (rw *requestLoggerResponseWriter) WriteHeader(code int) {
+	rw.statusCode = code
+	rw.ResponseWriter.WriteHeader(code)
+}
+
+func (rw *requestLoggerResponseWriter) Hijack() (net.Conn, *bufio.ReadWriter, error) {
+	h, ok := rw.ResponseWriter.(http.Hijacker)
+	if !ok {
+		return nil, nil, errors.New("ResponseWriter Interface does not support hijacking")
+	}
+	return h.Hijack()
+}
+
+type RequestLoggerMiddleware struct {
+	logger *logger.Logger
+}
+
+func NewRequestLoggerMiddleware(logger *logger.Logger) *RequestLoggerMiddleware {
+	return &RequestLoggerMiddleware{logger}
+}
+
+func (mw *RequestLoggerMiddleware) Middleware(next http.Handler) http.Handler {
+	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		start := time.Now()
+		rw := newRequestLoggerResponseWriter(w)
+
+		next.ServeHTTP(rw, r)
+
+		latency := time.Since(start)
+
+		event := mw.logger.Info().Dur("latency", latency).Int("status", rw.statusCode)
+
+		logger.AddLoggingContextScopes(r.Context(), event)
+		logger.AddLoggingRequestMeta(r, event)
+
+		event.Send()
+	})
+}

+ 45 - 0
api/server/router/middleware/websocket.go

@@ -0,0 +1,45 @@
+package middleware
+
+import (
+	"context"
+	"errors"
+	"net/http"
+
+	"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"
+)
+
+type WebsocketMiddleware struct {
+	config *config.Config
+}
+
+func NewWebsocketMiddleware(config *config.Config) *WebsocketMiddleware {
+	return &WebsocketMiddleware{config}
+}
+
+func (wm *WebsocketMiddleware) Middleware(next http.Handler) http.Handler {
+	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		conn, newRW, safeRW, err := wm.config.WSUpgrader.Upgrade(w, r, nil)
+
+		if err != nil {
+			if errors.Is(err, websocket.UpgraderCheckOriginErr) {
+				apierrors.HandleAPIError(wm.config, w, r, apierrors.NewErrForbidden(err), true)
+				return
+			} else {
+				apierrors.HandleAPIError(wm.config, w, r, apierrors.NewErrInternal(err), false)
+				return
+			}
+		}
+
+		w = newRW
+		defer conn.Close()
+
+		ctx := r.Context()
+		ctx = context.WithValue(ctx, types.RequestCtxWebsocketKey, safeRW)
+
+		r = r.Clone(ctx)
+		next.ServeHTTP(w, r)
+	})
+}

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

@@ -283,6 +283,7 @@ func getNamespaceRoutes(
 				types.ClusterScope,
 				types.NamespaceScope,
 			},
+			IsWebsocket: true,
 		},
 	)
 

+ 11 - 80
api/server/router/router.go

@@ -1,25 +1,19 @@
 package router
 
 import (
-	"bufio"
-	"errors"
-	"fmt"
-	"net"
 	"net/http"
 	"os"
 	"path"
 	"strings"
-	"time"
 
 	"github.com/go-chi/chi"
 	"github.com/porter-dev/porter/api/server/authn"
 	"github.com/porter-dev/porter/api/server/authz"
 	"github.com/porter-dev/porter/api/server/authz/policy"
+	"github.com/porter-dev/porter/api/server/router/middleware"
 	"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/logger"
 )
 
 func NewAPIRouter(config *config.Config) *chi.Mux {
@@ -54,14 +48,14 @@ func NewAPIRouter(config *config.Config) *chi.Mux {
 	)
 
 	userRegisterer := NewUserScopedRegisterer(projRegisterer)
-	panicMW := &PanicMiddleware{config}
+	panicMW := middleware.NewPanicMiddleware(config)
 
 	r.Route("/api", func(r chi.Router) {
 		// set panic middleware for all API endpoints to catch panics
 		r.Use(panicMW.Middleware)
 
 		// set the content type for all API endpoints and log all request info
-		r.Use(ContentTypeJSON)
+		r.Use(middleware.ContentTypeJSON)
 
 		baseRoutes := baseRegisterer.GetRoutes(
 			r,
@@ -190,7 +184,10 @@ func registerRoutes(config *config.Config, routes []*Route) {
 	policyDocLoader := policy.NewBasicPolicyDocumentLoader(config.Repo.Project())
 
 	// set up logging middleware to log information about the request
-	loggerMw := &RequestLoggerMiddleware{config.Logger}
+	loggerMw := middleware.NewRequestLoggerMiddleware(config.Logger)
+
+	// websocket middleware for upgrading requests
+	websocketMw := middleware.NewWebsocketMiddleware(config)
 
 	for _, route := range routes {
 		atomicGroup := route.Router.Group(nil)
@@ -232,6 +229,10 @@ func registerRoutes(config *config.Config, routes []*Route) {
 			atomicGroup.Use(loggerMw.Middleware)
 		}
 
+		if route.Endpoint.Metadata.IsWebsocket {
+			atomicGroup.Use(websocketMw.Middleware)
+		}
+
 		atomicGroup.Method(
 			string(route.Endpoint.Metadata.Method),
 			route.Endpoint.Metadata.Path.RelativePath,
@@ -239,73 +240,3 @@ func registerRoutes(config *config.Config, routes []*Route) {
 		)
 	}
 }
-
-// ContentTypeJSON sets the content type for requests to application/json
-func ContentTypeJSON(next http.Handler) http.Handler {
-	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
-		w.Header().Set("Content-Type", "application/json;charset=utf8")
-		next.ServeHTTP(w, r)
-	})
-}
-
-type requestLoggerResponseWriter struct {
-	http.ResponseWriter
-	statusCode int
-}
-
-func newRequestLoggerResponseWriter(w http.ResponseWriter) *requestLoggerResponseWriter {
-	return &requestLoggerResponseWriter{w, http.StatusOK}
-}
-
-func (rw *requestLoggerResponseWriter) WriteHeader(code int) {
-	rw.statusCode = code
-	rw.ResponseWriter.WriteHeader(code)
-}
-
-func (rw *requestLoggerResponseWriter) Hijack() (net.Conn, *bufio.ReadWriter, error) {
-	h, ok := rw.ResponseWriter.(http.Hijacker)
-	if !ok {
-		return nil, nil, errors.New("ResponseWriter Interface does not support hijacking")
-	}
-	return h.Hijack()
-}
-
-type RequestLoggerMiddleware struct {
-	logger *logger.Logger
-}
-
-func (mw *RequestLoggerMiddleware) Middleware(next http.Handler) http.Handler {
-	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
-		start := time.Now()
-		rw := newRequestLoggerResponseWriter(w)
-
-		next.ServeHTTP(rw, r)
-
-		latency := time.Since(start)
-
-		event := mw.logger.Info().Dur("latency", latency).Int("status", rw.statusCode)
-
-		logger.AddLoggingContextScopes(r.Context(), event)
-		logger.AddLoggingRequestMeta(r, event)
-
-		event.Send()
-	})
-}
-
-type PanicMiddleware struct {
-	config *config.Config
-}
-
-func (pmw *PanicMiddleware) Middleware(next http.Handler) http.Handler {
-	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
-		defer func() {
-			err := recover()
-
-			if err != nil {
-				apierrors.HandleAPIError(pmw.config, w, r, apierrors.NewErrInternal(fmt.Errorf("%v", err)))
-			}
-		}()
-
-		next.ServeHTTP(w, r)
-	})
-}

+ 16 - 13
api/server/shared/apierrors/errors.go

@@ -95,6 +95,7 @@ func HandleAPIError(
 	w http.ResponseWriter,
 	r *http.Request,
 	err RequestError,
+	writeErr bool,
 ) {
 	extErrorStr := err.ExternalError()
 
@@ -116,24 +117,26 @@ func HandleAPIError(
 		config.Alerter.SendAlert(r.Context(), err, data)
 	}
 
-	// send the external error
-	resp := &types.ExternalError{
-		Error: extErrorStr,
-	}
+	if writeErr {
+		// send the external error
+		resp := &types.ExternalError{
+			Error: extErrorStr,
+		}
 
-	// write the status code
-	w.WriteHeader(err.GetStatusCode())
+		// write the status code
+		w.WriteHeader(err.GetStatusCode())
 
-	writerErr := json.NewEncoder(w).Encode(resp)
+		writerErr := json.NewEncoder(w).Encode(resp)
 
-	if writerErr != nil {
-		event := config.Logger.Error().
-			Err(writerErr)
+		if writerErr != nil {
+			event := config.Logger.Error().
+				Err(writerErr)
 
-		logger.AddLoggingContextScopes(r.Context(), event)
-		logger.AddLoggingRequestMeta(r, event)
+			logger.AddLoggingContextScopes(r.Context(), event)
+			logger.AddLoggingRequestMeta(r, event)
 
-		event.Send()
+			event.Send()
+		}
 	}
 
 	return

+ 1 - 1
api/server/shared/apitest/request.go

@@ -65,7 +65,7 @@ func (f *failingDecoderValidator) DecodeAndValidate(
 	r *http.Request,
 	v interface{},
 ) (ok bool) {
-	apierrors.HandleAPIError(f.config, w, r, apierrors.NewErrInternal(fmt.Errorf("fake error")))
+	apierrors.HandleAPIError(f.config, w, r, apierrors.NewErrInternal(fmt.Errorf("fake error")), true)
 	return false
 }
 

+ 1 - 1
api/server/shared/config/config.go

@@ -2,9 +2,9 @@ package config
 
 import (
 	"github.com/gorilla/sessions"
-	"github.com/gorilla/websocket"
 	"github.com/porter-dev/porter/api/server/shared/apierrors/alerter"
 	"github.com/porter-dev/porter/api/server/shared/config/env"
+	"github.com/porter-dev/porter/api/server/shared/websocket"
 	"github.com/porter-dev/porter/internal/analytics"
 	"github.com/porter-dev/porter/internal/auth/token"
 	"github.com/porter-dev/porter/internal/helm/urlcache"

+ 9 - 6
api/server/shared/config/loader/loader.go

@@ -5,10 +5,11 @@ import (
 	"net/http"
 	"strconv"
 
-	"github.com/gorilla/websocket"
+	gorillaws "github.com/gorilla/websocket"
 	"github.com/porter-dev/porter/api/server/shared/apierrors/alerter"
 	"github.com/porter-dev/porter/api/server/shared/config"
 	"github.com/porter-dev/porter/api/server/shared/config/env"
+	"github.com/porter-dev/porter/api/server/shared/websocket"
 	"github.com/porter-dev/porter/internal/adapter"
 	"github.com/porter-dev/porter/internal/analytics"
 	"github.com/porter-dev/porter/internal/auth/sessionstore"
@@ -165,11 +166,13 @@ func (e *EnvConfigLoader) LoadConfig() (res *config.Config, err error) {
 	}
 
 	res.WSUpgrader = &websocket.Upgrader{
-		ReadBufferSize:  1024,
-		WriteBufferSize: 1024,
-		CheckOrigin: func(r *http.Request) bool {
-			origin := r.Header.Get("Origin")
-			return origin == sc.ServerURL
+		WSUpgrader: &gorillaws.Upgrader{
+			ReadBufferSize:  1024,
+			WriteBufferSize: 1024,
+			CheckOrigin: func(r *http.Request) bool {
+				origin := r.Header.Get("Origin")
+				return origin == sc.ServerURL
+			},
 		},
 	}
 

+ 2 - 2
api/server/shared/reader.go

@@ -38,13 +38,13 @@ func (j *DefaultRequestDecoderValidator) DecodeAndValidate(
 
 	// decode the request parameters (body and query)
 	if requestErr = j.decoder.Decode(v, r); requestErr != nil {
-		apierrors.HandleAPIError(j.config, w, r, requestErr)
+		apierrors.HandleAPIError(j.config, w, r, requestErr, true)
 		return false
 	}
 
 	// validate the request object
 	if requestErr = j.validator.Validate(v); requestErr != nil {
-		apierrors.HandleAPIError(j.config, w, r, requestErr)
+		apierrors.HandleAPIError(j.config, w, r, requestErr, true)
 		return false
 	}
 

+ 78 - 0
api/server/shared/websocket/response_writer.go

@@ -0,0 +1,78 @@
+package websocket
+
+import (
+	"errors"
+	"net/http"
+	"syscall"
+
+	"github.com/gorilla/websocket"
+)
+
+type WebsocketSafeReadWriter struct {
+	conn *websocket.Conn
+}
+
+func (w *WebsocketSafeReadWriter) WriteJSONWithChannel(v interface{}, errorChan chan<- error) {
+	err := w.conn.WriteJSON(v)
+
+	if err != nil {
+		if errOr(err, websocket.ErrCloseSent, syscall.EPIPE, syscall.ECONNRESET) {
+			// if close has been sent, or error is broken pipe error or connection reset, we want to
+			// send a message to the error channel to ensure closure but we ignore the error
+			errorChan <- nil
+		} else if err != nil {
+			errorChan <- err
+		}
+	}
+}
+
+func (w *WebsocketSafeReadWriter) Write(data []byte) (int, error) {
+	err := w.conn.WriteMessage(websocket.TextMessage, data)
+
+	if err != nil {
+		if errOr(err, websocket.ErrCloseSent, syscall.EPIPE, syscall.ECONNRESET) {
+			// if close has been sent, or error is broken pipe error or connection reset, we want to
+			// send a message to the error channel to ensure closure but we ignore the error
+			return 0, nil
+		} else if err != nil {
+			return 0, err
+		}
+	}
+
+	return len(data), nil
+}
+
+func (w *WebsocketSafeReadWriter) ReadMessage() (messageType int, p []byte, err error) {
+	return w.conn.ReadMessage()
+}
+
+type WebsocketResponseWriter struct {
+	conn       *websocket.Conn
+	safeWriter *WebsocketSafeReadWriter
+}
+
+// no HTTP headers in websocket protocol
+func (w *WebsocketResponseWriter) Header() http.Header {
+	return nil
+}
+
+// Write attempts to write a message to the websocket connection
+func (w *WebsocketResponseWriter) Write(data []byte) (int, error) {
+	return w.safeWriter.Write(data)
+}
+
+// no-op; no HTTP headers in websocket protocol
+func (w *WebsocketResponseWriter) WriteHeader(statusCode int) {
+	return
+}
+
+// helper that returns true when `err` matches any of the candidates
+func errOr(err error, candidates ...error) bool {
+	res := false
+
+	for _, cErr := range candidates {
+		res = res || errors.Is(err, cErr)
+	}
+
+	return res
+}

+ 34 - 0
api/server/shared/websocket/upgrader.go

@@ -0,0 +1,34 @@
+package websocket
+
+import (
+	"fmt"
+	"net/http"
+
+	"github.com/gorilla/websocket"
+)
+
+type Upgrader struct {
+	WSUpgrader *websocket.Upgrader
+}
+
+var UpgraderCheckOriginErr = fmt.Errorf("request origin not allowed by Upgrader.CheckOrigin")
+
+func (u *Upgrader) Upgrade(
+	w http.ResponseWriter,
+	r *http.Request,
+	responseHeader http.Header,
+) (*websocket.Conn, http.ResponseWriter, *WebsocketSafeReadWriter, error) {
+	// we manually call CheckOrigin and pass a specific error to the client in this case
+	check := u.WSUpgrader.CheckOrigin(r)
+
+	if !check {
+		return nil, nil, nil, UpgraderCheckOriginErr
+	}
+
+	conn, err := u.WSUpgrader.Upgrade(w, r, responseHeader)
+
+	safeWriter := &WebsocketSafeReadWriter{conn}
+	rw := &WebsocketResponseWriter{conn, safeWriter}
+
+	return conn, rw, safeWriter, err
+}

+ 3 - 3
api/server/shared/writer.go

@@ -27,11 +27,11 @@ func NewDefaultResultWriter(conf *config.Config) ResultWriter {
 func (j *DefaultResultWriter) WriteResult(w http.ResponseWriter, r *http.Request, v interface{}) {
 	err := json.NewEncoder(w).Encode(v)
 
-	if errors.Is(err, syscall.EPIPE) {
-		// broken pipe error, ignore. This means the client closed the connection while
+	if errors.Is(err, syscall.EPIPE) || errors.Is(err, syscall.ECONNRESET) {
+		// either a broken pipe error or econnreset, ignore. This means the client closed the connection while
 		// the server was sending bytes.
 		return
 	} else if err != nil {
-		apierrors.HandleAPIError(j.config, w, r, apierrors.NewErrInternal(err))
+		apierrors.HandleAPIError(j.config, w, r, apierrors.NewErrInternal(err), true)
 	}
 }

+ 5 - 0
api/types/request.go

@@ -60,6 +60,9 @@ type APIRequestMetadata struct {
 
 	// Whether the endpoint should log
 	Quiet bool
+
+	// Whether the endpoint upgrades to a websocket
+	IsWebsocket bool
 }
 
 const RequestScopeCtxKey = "requestscopes"
@@ -68,3 +71,5 @@ type RequestAction struct {
 	Verb     APIVerb
 	Resource NameOrUInt
 }
+
+var RequestCtxWebsocketKey = "websocket"

+ 82 - 50
dashboard/package-lock.json

@@ -2656,13 +2656,13 @@
       "dev": true
     },
     "@sentry/browser": {
-      "version": "6.12.0",
-      "resolved": "https://registry.npmjs.org/@sentry/browser/-/browser-6.12.0.tgz",
-      "integrity": "sha512-wsJi1NLOmfwtPNYxEC50dpDcVY7sdYckzwfqz1/zHrede1mtxpqSw+7iP4bHADOJXuF+ObYYTHND0v38GSXznQ==",
+      "version": "6.13.2",
+      "resolved": "https://registry.npmjs.org/@sentry/browser/-/browser-6.13.2.tgz",
+      "integrity": "sha512-bkFXK4vAp2UX/4rQY0pj2Iky55Gnwr79CtveoeeMshoLy5iDgZ8gvnLNAz7om4B9OQk1u7NzLEa4IXAmHTUyag==",
       "requires": {
-        "@sentry/core": "6.12.0",
-        "@sentry/types": "6.12.0",
-        "@sentry/utils": "6.12.0",
+        "@sentry/core": "6.13.2",
+        "@sentry/types": "6.13.2",
+        "@sentry/utils": "6.13.2",
         "tslib": "^1.9.3"
       },
       "dependencies": {
@@ -2674,14 +2674,14 @@
       }
     },
     "@sentry/core": {
-      "version": "6.12.0",
-      "resolved": "https://registry.npmjs.org/@sentry/core/-/core-6.12.0.tgz",
-      "integrity": "sha512-mU/zdjlzFHzdXDZCPZm8OeCw7c9xsbL49Mq0TrY0KJjLt4CJBkiq5SDTGfRsenBLgTedYhe5Z/J8Z+xVVq+MfQ==",
-      "requires": {
-        "@sentry/hub": "6.12.0",
-        "@sentry/minimal": "6.12.0",
-        "@sentry/types": "6.12.0",
-        "@sentry/utils": "6.12.0",
+      "version": "6.13.2",
+      "resolved": "https://registry.npmjs.org/@sentry/core/-/core-6.13.2.tgz",
+      "integrity": "sha512-snXNNFLwlS7yYxKTX4DBXebvJK+6ikBWN6noQ1CHowvM3ReFBlrdrs0Z0SsSFEzXm2S4q7f6HHbm66GSQZ/8FQ==",
+      "requires": {
+        "@sentry/hub": "6.13.2",
+        "@sentry/minimal": "6.13.2",
+        "@sentry/types": "6.13.2",
+        "@sentry/utils": "6.13.2",
         "tslib": "^1.9.3"
       },
       "dependencies": {
@@ -2693,12 +2693,12 @@
       }
     },
     "@sentry/hub": {
-      "version": "6.12.0",
-      "resolved": "https://registry.npmjs.org/@sentry/hub/-/hub-6.12.0.tgz",
-      "integrity": "sha512-yR/UQVU+ukr42bSYpeqvb989SowIXlKBanU0cqLFDmv5LPCnaQB8PGeXwJAwWhQgx44PARhmB82S6Xor8gYNxg==",
+      "version": "6.13.2",
+      "resolved": "https://registry.npmjs.org/@sentry/hub/-/hub-6.13.2.tgz",
+      "integrity": "sha512-sppSuJdNMiMC/vFm/dQowCBh11uTrmvks00fc190YWgxHshodJwXMdpc+pN61VSOmy2QA4MbQ5aMAgHzPzel3A==",
       "requires": {
-        "@sentry/types": "6.12.0",
-        "@sentry/utils": "6.12.0",
+        "@sentry/types": "6.13.2",
+        "@sentry/utils": "6.13.2",
         "tslib": "^1.9.3"
       },
       "dependencies": {
@@ -2710,12 +2710,12 @@
       }
     },
     "@sentry/minimal": {
-      "version": "6.12.0",
-      "resolved": "https://registry.npmjs.org/@sentry/minimal/-/minimal-6.12.0.tgz",
-      "integrity": "sha512-r3C54Q1KN+xIqUvcgX9DlcoWE7ezWvFk2pSu1Ojx9De81hVqR9u5T3sdSAP2Xma+um0zr6coOtDJG4WtYlOtsw==",
+      "version": "6.13.2",
+      "resolved": "https://registry.npmjs.org/@sentry/minimal/-/minimal-6.13.2.tgz",
+      "integrity": "sha512-6iJfEvHzzpGBHDfLxSHcGObh73XU1OSQKWjuhDOe7UQDyI4BQmTfcXAC+Fr8sm8C/tIsmpVi/XJhs8cubFdSMw==",
       "requires": {
-        "@sentry/hub": "6.12.0",
-        "@sentry/types": "6.12.0",
+        "@sentry/hub": "6.13.2",
+        "@sentry/types": "6.13.2",
         "tslib": "^1.9.3"
       },
       "dependencies": {
@@ -2727,14 +2727,14 @@
       }
     },
     "@sentry/react": {
-      "version": "6.12.0",
-      "resolved": "https://registry.npmjs.org/@sentry/react/-/react-6.12.0.tgz",
-      "integrity": "sha512-E8Nw9PPzP/EyMy64ksr9xcyYYlBmUA5ROnkPQp7o5wF0xf5/J+nMS1tQdyPnLQe2KUgHlN4kVs2HHft1m7mSYQ==",
-      "requires": {
-        "@sentry/browser": "6.12.0",
-        "@sentry/minimal": "6.12.0",
-        "@sentry/types": "6.12.0",
-        "@sentry/utils": "6.12.0",
+      "version": "6.13.2",
+      "resolved": "https://registry.npmjs.org/@sentry/react/-/react-6.13.2.tgz",
+      "integrity": "sha512-aLkWyn697LTcmK1PPnUg5UJcyBUPoI68motqgBY53SIYDAwOeYNUQt2aanDuOTY5aE2PdnJwU48klA8vuYkoRQ==",
+      "requires": {
+        "@sentry/browser": "6.13.2",
+        "@sentry/minimal": "6.13.2",
+        "@sentry/types": "6.13.2",
+        "@sentry/utils": "6.13.2",
         "hoist-non-react-statics": "^3.3.2",
         "tslib": "^1.9.3"
       },
@@ -2747,14 +2747,14 @@
       }
     },
     "@sentry/tracing": {
-      "version": "6.12.0",
-      "resolved": "https://registry.npmjs.org/@sentry/tracing/-/tracing-6.12.0.tgz",
-      "integrity": "sha512-u10QHNknPBzbWSUUNMkvuH53sQd5NaBo6YdNPj4p5b7sE7445Sh0PwBpRbY3ZiUUiwyxV59fx9UQ4yVnPGxZQA==",
-      "requires": {
-        "@sentry/hub": "6.12.0",
-        "@sentry/minimal": "6.12.0",
-        "@sentry/types": "6.12.0",
-        "@sentry/utils": "6.12.0",
+      "version": "6.13.2",
+      "resolved": "https://registry.npmjs.org/@sentry/tracing/-/tracing-6.13.2.tgz",
+      "integrity": "sha512-bHJz+C/nd6biWTNcYAu91JeRilsvVgaye4POkdzWSmD0XoLWHVMrpCQobGpXe7onkp2noU3YQjhqgtBqPHtnpw==",
+      "requires": {
+        "@sentry/hub": "6.13.2",
+        "@sentry/minimal": "6.13.2",
+        "@sentry/types": "6.13.2",
+        "@sentry/utils": "6.13.2",
         "tslib": "^1.9.3"
       },
       "dependencies": {
@@ -2766,16 +2766,16 @@
       }
     },
     "@sentry/types": {
-      "version": "6.12.0",
-      "resolved": "https://registry.npmjs.org/@sentry/types/-/types-6.12.0.tgz",
-      "integrity": "sha512-urtgLzE4EDMAYQHYdkgC0Ei9QvLajodK1ntg71bGn0Pm84QUpaqpPDfHRU+i6jLeteyC7kWwa5O5W1m/jrjGXA=="
+      "version": "6.13.2",
+      "resolved": "https://registry.npmjs.org/@sentry/types/-/types-6.13.2.tgz",
+      "integrity": "sha512-6WjGj/VjjN8LZDtqJH5ikeB1o39rO1gYS6anBxiS3d0sXNBb3Ux0pNNDFoBxQpOhmdDHXYS57MEptX9EV82gmg=="
     },
     "@sentry/utils": {
-      "version": "6.12.0",
-      "resolved": "https://registry.npmjs.org/@sentry/utils/-/utils-6.12.0.tgz",
-      "integrity": "sha512-oRHQ7TH5TSsJqoP9Gqq25Jvn9LKexXfAh/OoKwjMhYCGKGhqpDNUIZVgl9DWsGw5A5N5xnQyLOxDfyRV5RshdA==",
+      "version": "6.13.2",
+      "resolved": "https://registry.npmjs.org/@sentry/utils/-/utils-6.13.2.tgz",
+      "integrity": "sha512-foF4PbxqPMWNbuqdXkdoOmKm3quu3PP7Q7j/0pXkri4DtCuvF/lKY92mbY0V9rHS/phCoj+3/Se5JvM2ymh2/w==",
       "requires": {
-        "@sentry/types": "6.12.0",
+        "@sentry/types": "6.13.2",
         "tslib": "^1.9.3"
       },
       "dependencies": {
@@ -5994,7 +5994,6 @@
       "version": "2.0.6",
       "resolved": "https://registry.npmjs.org/error-stack-parser/-/error-stack-parser-2.0.6.tgz",
       "integrity": "sha512-d51brTeqC+BHlwF0BhPtcYgF5nlzf9ZZ0ZIUQNZpc9ZB9qw5IJ2diTrBY9jlCJkTLITYPjmiX6OWCwH+fuyNgQ==",
-      "dev": true,
       "requires": {
         "stackframe": "^1.1.1"
       }
@@ -10437,11 +10436,44 @@
         "figgy-pudding": "^3.5.1"
       }
     },
+    "stack-generator": {
+      "version": "2.0.5",
+      "resolved": "https://registry.npmjs.org/stack-generator/-/stack-generator-2.0.5.tgz",
+      "integrity": "sha512-/t1ebrbHkrLrDuNMdeAcsvynWgoH/i4o8EGGfX7dEYDoTXOYVAkEpFdtshlvabzc6JlJ8Kf9YdFEoz7JkzGN9Q==",
+      "requires": {
+        "stackframe": "^1.1.1"
+      }
+    },
     "stackframe": {
       "version": "1.2.0",
       "resolved": "https://registry.npmjs.org/stackframe/-/stackframe-1.2.0.tgz",
-      "integrity": "sha512-GrdeshiRmS1YLMYgzF16olf2jJ/IzxXY9lhKOskuVziubpTYcYqyOwYeJKzQkwy7uN0fYSsbsC4RQaXf9LCrYA==",
-      "dev": true
+      "integrity": "sha512-GrdeshiRmS1YLMYgzF16olf2jJ/IzxXY9lhKOskuVziubpTYcYqyOwYeJKzQkwy7uN0fYSsbsC4RQaXf9LCrYA=="
+    },
+    "stacktrace-gps": {
+      "version": "3.0.4",
+      "resolved": "https://registry.npmjs.org/stacktrace-gps/-/stacktrace-gps-3.0.4.tgz",
+      "integrity": "sha512-qIr8x41yZVSldqdqe6jciXEaSCKw1U8XTXpjDuy0ki/apyTn/r3w9hDAAQOhZdxvsC93H+WwwEu5cq5VemzYeg==",
+      "requires": {
+        "source-map": "0.5.6",
+        "stackframe": "^1.1.1"
+      },
+      "dependencies": {
+        "source-map": {
+          "version": "0.5.6",
+          "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.6.tgz",
+          "integrity": "sha1-dc449SvwczxafwwRjYEzSiu19BI="
+        }
+      }
+    },
+    "stacktrace-js": {
+      "version": "2.0.2",
+      "resolved": "https://registry.npmjs.org/stacktrace-js/-/stacktrace-js-2.0.2.tgz",
+      "integrity": "sha512-Je5vBeY4S1r/RnLydLl0TBTi3F2qdfWmYsGvtfZgEI+SCprPppaIhQf5nGcal4gI4cGpCV/duLcAzT1np6sQqg==",
+      "requires": {
+        "error-stack-parser": "^2.0.6",
+        "stack-generator": "^2.0.5",
+        "stacktrace-gps": "^3.0.4"
+      }
     },
     "static-extend": {
       "version": "0.1.2",

+ 3 - 2
dashboard/package.json

@@ -4,8 +4,8 @@
   "private": true,
   "dependencies": {
     "@material-ui/core": "^4.11.3",
-    "@sentry/react": "^6.12.0",
-    "@sentry/tracing": "^6.12.0",
+    "@sentry/react": "^6.13.2",
+    "@sentry/tracing": "^6.13.2",
     "@visx/axis": "^1.6.1",
     "@visx/curve": "^1.0.0",
     "@visx/event": "^1.3.0",
@@ -43,6 +43,7 @@
     "react-transition-group": "^4.4.2",
     "regenerator-runtime": "^0.13.9",
     "semver": "^7.3.5",
+    "stacktrace-js": "^2.0.2",
     "styled-components": "^5.2.0"
   },
   "scripts": {

+ 1 - 1
dashboard/src/App.tsx

@@ -1,6 +1,6 @@
 import React, { Component } from "react";
 import { BrowserRouter } from "react-router-dom";
-import PorterErrorBoundary from "shared/PorterErrorBoundary";
+import PorterErrorBoundary from "shared/error_handling/PorterErrorBoundary";
 import styled, { createGlobalStyle } from "styled-components";
 
 import MainWrapper from "./main/MainWrapper";

+ 2 - 2
dashboard/src/components/UnexpectedErrorPage.tsx

@@ -1,7 +1,7 @@
 import React from "react";
 import styled from "styled-components";
 
-const UnexpectedErrorPage = ({ error, resetError }: any) => (
+const UnexpectedErrorPage = ({ error, resetErrorBoundary }: any) => (
   <>
     <StyledPageNotFound>
       <Mega>
@@ -9,7 +9,7 @@ const UnexpectedErrorPage = ({ error, resetError }: any) => (
         <Inside>Unknown Error</Inside>
       </Mega>
       <Flex>
-        <BackButton width="140px" onClick={() => resetError(error)}>
+        <BackButton width="140px" onClick={() => resetErrorBoundary(error)}>
           <i className="material-icons">arrow_back</i>
           Reload page
         </BackButton>

+ 4 - 1
dashboard/src/index.tsx

@@ -4,7 +4,8 @@ import "regenerator-runtime/runtime";
 import * as React from "react";
 import * as ReactDOM from "react-dom";
 import App from "./App";
-import { SetupSentry } from "shared/sentry/setup";
+import { SetupSentry } from "shared/error_handling/sentry/setup";
+import { EnableErrorHandling } from "shared/error_handling/window_error_handling";
 
 declare global {
   interface Window {
@@ -15,4 +16,6 @@ if (process.env.ENABLE_SENTRY) {
   SetupSentry();
 }
 
+EnableErrorHandling();
+
 ReactDOM.render(<App />, document.getElementById("output"));

+ 4 - 1
dashboard/src/main/MainWrapper.tsx

@@ -4,6 +4,7 @@ import { ContextProvider } from "../shared/Context";
 import Main from "./Main";
 import { RouteComponentProps, withRouter } from "react-router";
 import AuthProvider from "shared/auth/AuthContext";
+import MainWrapperErrorBoundary from "shared/error_handling/MainWrapperErrorBoundary";
 
 type PropsType = RouteComponentProps & {};
 
@@ -15,7 +16,9 @@ class MainWrapper extends Component<PropsType, StateType> {
     return (
       <ContextProvider history={history} location={location}>
         <AuthProvider>
-          <Main />
+          <MainWrapperErrorBoundary>
+            <Main />
+          </MainWrapperErrorBoundary>
         </AuthProvider>
       </ContextProvider>
     );

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

@@ -97,9 +97,7 @@ const Chart: React.FunctionComponent<Props> = ({
         let urlParams = new URLSearchParams(location.search);
         let cluster = urlParams.get("cluster");
         let route = `${match.url}/${cluster}/${chart.namespace}/${chart.name}`;
-        pushFiltered({ location, history }, route, ["project_id"], {
-          chart_revision: chart.version,
-        });
+        pushFiltered({ location, history }, route, ["project_id"]);
       }}
     >
       <Title>

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

@@ -33,15 +33,17 @@ class ExpandedChartWrapper extends Component<PropsType, StateType> {
     let { namespace, chartName } = match.params as any;
     let { currentProject, currentCluster } = this.context;
     if (currentProject && currentCluster) {
-      // TODO: add query for retrieving max revision #
-      const lastCheckedRevision = getQueryParam(this.props, "chart_revision");
-
       api
         .getChart(
           "<token>",
+          {},
           {
-          },
-          { id: currentProject.id, namespace: namespace, cluster_id: currentCluster.id ,name: chartName, revision: Number(lastCheckedRevision), }
+            id: currentProject.id,
+            namespace: namespace,
+            cluster_id: currentCluster.id,
+            name: chartName,
+            revision: 0,
+          }
         )
         .then((res) => {
           this.setState({ currentChart: res.data, loading: false });

+ 0 - 2
dashboard/src/main/home/launch/launch-flow/SettingsPage.tsx

@@ -54,8 +54,6 @@ class SettingsPage extends Component<PropsType, StateType> {
   };
 
   componentDidMount() {
-    window.scrollBy(0, -window.innerHeight);
-
     // Retrieve tab options
     let tabOptions = [] as ChoiceType[];
     this.props.form?.tabs.map((tab: any, i: number) => {

+ 2 - 2
dashboard/src/main/home/provisioner/AWSFormSection.tsx

@@ -200,8 +200,8 @@ const AWSFormSectionFC: React.FC<PropsType> = (props) => {
         "<token>",
         {
           aws_region: awsRegion,
-          aws_access_key_id: awsAccessId,
-          aws_secret_access_key: awsSecretKey,
+          aws_access_key_id: awsAccessId.trim(),
+          aws_secret_access_key: awsSecretKey.trim(),
           aws_cluster_id: clusterName,
         },
         { id: currentProject.id }

+ 0 - 53
dashboard/src/shared/PorterErrorBoundary.tsx

@@ -1,53 +0,0 @@
-import UnexpectedErrorPage from "components/UnexpectedErrorPage";
-import React from "react";
-import { ErrorBoundary } from "react-error-boundary";
-import * as Sentry from "@sentry/react";
-
-export type PorterErrorBoundaryProps<OnResetProps = {}> = {
-  // Component or useful name to describe where the error boundary was setted
-  errorBoundaryLocation: string;
-  // Used in case the boundary shouldn't refresh but instead do other action
-  onReset?: (props: OnResetProps) => unknown;
-};
-
-const PorterErrorBoundary: React.FC<PorterErrorBoundaryProps> = ({
-  errorBoundaryLocation,
-  onReset,
-  children,
-}) => {
-  const handleError = (error: Error, info: { componentStack: string }) => {
-    if (process.env.ENABLE_SENTRY) {
-      Sentry.captureException(error, (scope) => {
-        scope.setTags({
-          error_boundary_location: errorBoundaryLocation,
-          error_message: error?.message,
-          component_stack: info?.componentStack,
-        });
-        return scope;
-      });
-    }
-
-    window?.analytics?.track("React Error", {
-      location: errorBoundaryLocation,
-      error: error.message,
-      componentStack: info?.componentStack,
-      url: window.location.toString(),
-    });
-  };
-
-  const handleOnReset = (props: unknown) => {
-    typeof onReset === "function" ? onReset(props) : window.location.reload();
-  };
-
-  return (
-    <ErrorBoundary
-      onError={handleError}
-      FallbackComponent={UnexpectedErrorPage}
-      onReset={handleOnReset}
-    >
-      {children}
-    </ErrorBoundary>
-  );
-};
-
-export default PorterErrorBoundary;

+ 33 - 0
dashboard/src/shared/error_handling/MainWrapperErrorBoundary.tsx

@@ -0,0 +1,33 @@
+import React, { useContext } from "react";
+import { Context } from "shared/Context";
+import PorterErrorBoundary from "./PorterErrorBoundary";
+
+const MainWrapperErrorBoundary: React.FC = ({ children }) => {
+  const location = "MainWrapperErrorBoundary";
+  const {
+    capabilities,
+    currentCluster,
+    currentProject,
+    devOpsMode,
+    projects,
+  } = useContext(Context);
+
+  return (
+    <PorterErrorBoundary
+      errorBoundaryLocation={location}
+      context={{
+        "Global context state": {
+          capabilities,
+          currentProject,
+          currentCluster,
+          devOpsMode,
+          projects: JSON.stringify(projects),
+        },
+      }}
+    >
+      {children}
+    </PorterErrorBoundary>
+  );
+};
+
+export default MainWrapperErrorBoundary;

+ 84 - 0
dashboard/src/shared/error_handling/PorterErrorBoundary.tsx

@@ -0,0 +1,84 @@
+import UnexpectedErrorPage from "components/UnexpectedErrorPage";
+import React from "react";
+import { ErrorBoundary } from "react-error-boundary";
+import * as Sentry from "@sentry/react";
+import StackTrace from "stacktrace-js";
+import { Context, Primitive } from "@sentry/types";
+import { stackFramesToString } from "./stack_trace_utils";
+
+export type PorterErrorBoundaryProps<OnResetProps = {}> = {
+  // Component or useful name to describe where the error boundary was setted
+  errorBoundaryLocation: string;
+  // Used in case the boundary shouldn't refresh but instead do other action
+  onReset?: (props: OnResetProps) => unknown;
+  // Add more tags to sentry errors
+  tags?: {
+    [key: string]: Primitive;
+  };
+  // Add more context for sentry errors
+  context?: {
+    [key: string]: Context;
+  };
+};
+
+const PorterErrorBoundary: React.FC<PorterErrorBoundaryProps> = ({
+  errorBoundaryLocation,
+  onReset,
+  children,
+  tags,
+  context,
+}) => {
+  const handleError = (err: Error) => {
+    StackTrace.fromError(err).then((stackframes) => {
+      const stackFramesStringify = stackFramesToString(stackframes);
+      // Preserve the old stack just in case
+      const originalStack = err.stack;
+      // Update the error stack with the StackTrace stack (this helps for minified environments)
+      err.stack = stackFramesStringify;
+
+      if (process.env.ENABLE_SENTRY) {
+        Sentry.captureException(err, (scope) => {
+          scope.setTags({
+            error_boundary_location: errorBoundaryLocation,
+            error_message: err?.message,
+            ...(tags || {}),
+          });
+          scope.setContext("Original stack", {
+            originalStack,
+          });
+
+          if (typeof context === "object") {
+            Object.entries(context).forEach(([contextName, contextContent]) => {
+              scope.setContext(contextName, contextContent);
+            });
+          }
+
+          return scope;
+        });
+      }
+
+      window?.analytics?.track("React Error", {
+        location: errorBoundaryLocation,
+        error: stackFramesStringify,
+        componentStack: err.stack,
+        url: window.location.toString(),
+      });
+    });
+  };
+
+  const handleOnReset = (props: unknown) => {
+    typeof onReset === "function" ? onReset(props) : window.location.reload();
+  };
+
+  return (
+    <ErrorBoundary
+      onError={handleError}
+      FallbackComponent={UnexpectedErrorPage}
+      onReset={handleOnReset}
+    >
+      {children}
+    </ErrorBoundary>
+  );
+};
+
+export default PorterErrorBoundary;

+ 3 - 2
dashboard/src/shared/sentry/setup.ts → dashboard/src/shared/error_handling/sentry/setup.ts

@@ -2,6 +2,7 @@ import * as Sentry from "@sentry/react";
 import { Integrations } from "@sentry/tracing";
 
 const SENTRY_DSN = process.env.SENTRY_DSN;
+const SENTRY_ENV = process.env.SENTRY_ENV || "development";
 
 export const SetupSentry = () => {
   if (!SENTRY_DSN) {
@@ -10,8 +11,8 @@ export const SetupSentry = () => {
   Sentry.init({
     dsn: SENTRY_DSN,
     integrations: [new Integrations.BrowserTracing()],
-
+    environment: SENTRY_ENV,
     // Check out https://docs.sentry.io/platforms/javascript/guides/react/configuration/sampling/ for a more refined sample rate
-    tracesSampleRate: 0.25,
+    tracesSampleRate: 1,
   });
 };

+ 9 - 0
dashboard/src/shared/error_handling/stack_trace_utils.ts

@@ -0,0 +1,9 @@
+import { StackFrame } from "stacktrace-js";
+
+export const stackFramesToString = (stackFrames: StackFrame[]) => {
+  return stackFrames
+    .map(function (sf) {
+      return sf.toString();
+    })
+    .join("\n");
+};

+ 35 - 0
dashboard/src/shared/error_handling/window_error_handling.ts

@@ -0,0 +1,35 @@
+import { stackFramesToString } from "./stack_trace_utils";
+import * as Sentry from "@sentry/react";
+
+export function EnableErrorHandling() {
+  window.onerror = function (msg, file, line, col, err) {
+    StackTrace.fromError(err).then((stackframes) => {
+      const stackFramesStringify = stackFramesToString(stackframes);
+      // Preserve the old stack just in case
+      const originalStack = err.stack;
+      // Update the error stack with the StackTrace stack (this helps for minified environments)
+      err.stack = stackFramesStringify;
+
+      if (process.env.ENABLE_SENTRY) {
+        Sentry.captureException(err, (scope) => {
+          scope.setTags({
+            error_boundary_location: "window_error_handling",
+            error_message: err?.message,
+          });
+          scope.setContext("Original stack", {
+            originalStack,
+          });
+
+          return scope;
+        });
+      }
+
+      window?.analytics?.track("React Error", {
+        location: "window_error_handling",
+        error: stackFramesStringify,
+        componentStack: err.stack,
+        url: window.location.toString(),
+      });
+    });
+  };
+}

+ 1 - 0
dashboard/webpack.config.js

@@ -28,6 +28,7 @@ module.exports = () => {
     entry: ["./src/index.tsx"],
     target: "web",
     mode: isDevelopment ? "development" : "production",
+    devtool: "source-map",
     module: {
       rules: [
         {

+ 4 - 0
internal/analytics/track_events.go

@@ -30,4 +30,8 @@ const (
 	ApplicationLaunchSuccess SegmentEvent = "Application Launch Success"
 
 	ApplicationDeploymentWebhook SegmentEvent = "Triggered Re-deploy via Webhook"
+
+	// delete events
+	ClusterDestroyingStart   SegmentEvent = "Cluster Destroying Start"
+	ClusterDestroyingSuccess SegmentEvent = "Cluster Destroying Success"
 )

+ 44 - 0
internal/analytics/tracks.go

@@ -442,3 +442,47 @@ func RegistryProvisioningSuccessTrack(opts *RegistryProvisioningSuccessTrackOpts
 		getDefaultSegmentTrack(additionalProps, RegistryProvisioningSuccess),
 	)
 }
+
+// ClusterDestroyingStartTrackOpts are the options for creating a track when a cluster
+// has started destroying
+type ClusterDestroyingStartTrackOpts struct {
+	*ClusterScopedTrackOpts
+
+	ClusterType types.InfraKind
+	InfraID     uint
+}
+
+// ClusterDestroyingStartTrack returns a track for when a cluster
+// has started destroying
+func ClusterDestroyingStartTrack(opts *ClusterDestroyingStartTrackOpts) segmentTrack {
+	additionalProps := make(map[string]interface{})
+	additionalProps["cluster_type"] = opts.ClusterType
+	additionalProps["infra_id"] = opts.InfraID
+
+	return getSegmentClusterTrack(
+		opts.ClusterScopedTrackOpts,
+		getDefaultSegmentTrack(additionalProps, ClusterDestroyingStart),
+	)
+}
+
+// ClusterDestroyingSuccessTrackOpts are the options for creating a track when a cluster
+// has successfully provisioned
+type ClusterDestroyingSuccessTrackOpts struct {
+	*ClusterScopedTrackOpts
+
+	ClusterType types.InfraKind
+	InfraID     uint
+}
+
+// ClusterDestroyingSuccessTrack returns a new track for when a cluster
+// has successfully provisioned
+func ClusterDestroyingSuccessTrack(opts *ClusterDestroyingSuccessTrackOpts) segmentTrack {
+	additionalProps := make(map[string]interface{})
+	additionalProps["cluster_type"] = opts.ClusterType
+	additionalProps["infra_id"] = opts.InfraID
+
+	return getSegmentClusterTrack(
+		opts.ClusterScopedTrackOpts,
+		getDefaultSegmentTrack(additionalProps, ClusterDestroyingSuccess),
+	)
+}

+ 117 - 39
internal/kubernetes/agent.go

@@ -13,7 +13,10 @@ import (
 	"strings"
 	"time"
 
+	goerrors "errors"
+
 	"github.com/porter-dev/porter/api/server/shared/config/env"
+	"github.com/porter-dev/porter/api/server/shared/websocket"
 	"github.com/porter-dev/porter/internal/kubernetes/provisioner"
 	"github.com/porter-dev/porter/internal/kubernetes/provisioner/aws"
 	"github.com/porter-dev/porter/internal/kubernetes/provisioner/aws/ecr"
@@ -32,7 +35,6 @@ import (
 
 	errors2 "errors"
 
-	"github.com/gorilla/websocket"
 	"github.com/porter-dev/porter/internal/helm/grapher"
 	appsv1 "k8s.io/api/apps/v1"
 	batchv1 "k8s.io/api/batch/v1"
@@ -41,9 +43,11 @@ import (
 	v1beta1 "k8s.io/api/extensions/v1beta1"
 	"k8s.io/apimachinery/pkg/api/errors"
 	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+	"k8s.io/apimachinery/pkg/fields"
 	"k8s.io/apimachinery/pkg/runtime"
 	"k8s.io/apimachinery/pkg/runtime/schema"
 	"k8s.io/apimachinery/pkg/types"
+	"k8s.io/apimachinery/pkg/watch"
 	"k8s.io/cli-runtime/pkg/genericclioptions"
 	"k8s.io/client-go/informers"
 	"k8s.io/client-go/kubernetes"
@@ -367,6 +371,14 @@ func (a *Agent) GetIngress(namespace string, name string) (*v1beta1.Ingress, err
 
 var IsNotFoundError = fmt.Errorf("not found")
 
+type BadRequestError struct {
+	msg string
+}
+
+func (e *BadRequestError) Error() string {
+	return e.msg
+}
+
 // GetDeployment gets the deployment given the name and namespace
 func (a *Agent) GetDeployment(c grapher.Object) (*appsv1.Deployment, error) {
 	res, err := a.Clientset.AppsV1().Deployments(c.Namespace).Get(
@@ -508,7 +520,7 @@ func (a *Agent) DeletePod(namespace string, name string) error {
 }
 
 // GetPodLogs streams real-time logs from a given pod.
-func (a *Agent) GetPodLogs(namespace string, name string, conn *websocket.Conn) error {
+func (a *Agent) GetPodLogs(namespace string, name string, rw *websocket.WebsocketSafeReadWriter) error {
 	// get the pod to read in the list of contains
 	pod, err := a.Clientset.CoreV1().Pods(namespace).Get(
 		context.Background(),
@@ -522,6 +534,19 @@ func (a *Agent) GetPodLogs(namespace string, name string, conn *websocket.Conn)
 		return fmt.Errorf("Cannot get logs from pod %s: %s", name, err.Error())
 	}
 
+	// see if container is ready and able to open a stream. If not, wait for container
+	// to be ready.
+	err, isExited := a.waitForPod(pod)
+
+	if err != nil && goerrors.Is(err, IsNotFoundError) {
+		return IsNotFoundError
+	} else if err != nil {
+		return fmt.Errorf("Cannot get logs from pod %s: %s", name, err.Error())
+	} else if isExited {
+		// if exited, we return nil and simply close the stream
+		return nil
+	}
+
 	container := pod.Spec.Containers[0].Name
 
 	tails := int64(400)
@@ -537,9 +562,14 @@ func (a *Agent) GetPodLogs(namespace string, name string, conn *websocket.Conn)
 
 	podLogs, err := req.Stream(context.TODO())
 
-	if err != nil {
+	// in the case of bad request errors, such as if the pod is stuck in "ContainerCreating",
+	// we'd like to pass this through to the client.
+	if err != nil && errors.IsBadRequest(err) {
+		return &BadRequestError{err.Error()}
+	} else if err != nil {
 		return fmt.Errorf("Cannot open log stream for pod %s: %s", name, err.Error())
 	}
+
 	defer podLogs.Close()
 
 	r := bufio.NewReader(podLogs)
@@ -548,8 +578,7 @@ func (a *Agent) GetPodLogs(namespace string, name string, conn *websocket.Conn)
 	go func() {
 		// listens for websocket closing handshake
 		for {
-			if _, _, err := conn.ReadMessage(); err != nil {
-				defer conn.Close()
+			if _, _, err := rw.ReadMessage(); err != nil {
 				errorchan <- nil
 				return
 			}
@@ -566,7 +595,7 @@ func (a *Agent) GetPodLogs(namespace string, name string, conn *websocket.Conn)
 			}
 
 			bytes, err := r.ReadBytes('\n')
-			if writeErr := conn.WriteMessage(websocket.TextMessage, bytes); writeErr != nil {
+			if _, writeErr := rw.Write(bytes); writeErr != nil {
 				errorchan <- writeErr
 				return
 			}
@@ -669,7 +698,7 @@ func (a *Agent) RunWebsocketTask(task func() error) error {
 
 // StreamControllerStatus streams controller status. Supports Deployment, StatefulSet, ReplicaSet, and DaemonSet
 // TODO: Support Jobs
-func (a *Agent) StreamControllerStatus(conn *websocket.Conn, kind string, selectors string) error {
+func (a *Agent) StreamControllerStatus(kind string, selectors string, rw *websocket.WebsocketSafeReadWriter) error {
 
 	run := func() error {
 		// selectors is an array of max length 1. StreamControllerStatus accepts calls without the selectors argument.
@@ -723,10 +752,7 @@ func (a *Agent) StreamControllerStatus(conn *websocket.Conn, kind string, select
 					Object:    newObj,
 					Kind:      strings.ToLower(kind),
 				}
-				if writeErr := conn.WriteJSON(msg); writeErr != nil {
-					errorchan <- writeErr
-					return
-				}
+				rw.WriteJSONWithChannel(msg, errorchan)
 			},
 			AddFunc: func(obj interface{}) {
 				msg := Message{
@@ -734,11 +760,7 @@ func (a *Agent) StreamControllerStatus(conn *websocket.Conn, kind string, select
 					Object:    obj,
 					Kind:      strings.ToLower(kind),
 				}
-
-				if writeErr := conn.WriteJSON(msg); writeErr != nil {
-					errorchan <- writeErr
-					return
-				}
+				rw.WriteJSONWithChannel(msg, errorchan)
 			},
 			DeleteFunc: func(obj interface{}) {
 				msg := Message{
@@ -746,19 +768,14 @@ func (a *Agent) StreamControllerStatus(conn *websocket.Conn, kind string, select
 					Object:    obj,
 					Kind:      strings.ToLower(kind),
 				}
-
-				if writeErr := conn.WriteJSON(msg); writeErr != nil {
-					errorchan <- writeErr
-					return
-				}
+				rw.WriteJSONWithChannel(msg, errorchan)
 			},
 		})
 
 		go func() {
 			// listens for websocket closing handshake
 			for {
-				if _, _, err := conn.ReadMessage(); err != nil {
-					conn.Close()
+				if _, _, err := rw.ReadMessage(); err != nil {
 					errorchan <- nil
 					return
 				}
@@ -847,8 +864,7 @@ func parseSecretToHelmRelease(secret v1.Secret, chartList []string) (*rspb.Relea
 	return helm_object, false, nil
 }
 
-func (a *Agent) StreamHelmReleases(conn *websocket.Conn, namespace string, chartList []string, selectors string) error {
-
+func (a *Agent) StreamHelmReleases(namespace string, chartList []string, selectors string, rw *websocket.WebsocketSafeReadWriter) error {
 	run := func() error {
 		tweakListOptionsFunc := func(options *metav1.ListOptions) {
 			options.LabelSelector = selectors
@@ -898,10 +914,7 @@ func (a *Agent) StreamHelmReleases(conn *websocket.Conn, namespace string, chart
 					Object:    helm_object,
 				}
 
-				if writeErr := conn.WriteJSON(msg); writeErr != nil {
-					errorchan <- writeErr
-					return
-				}
+				rw.WriteJSONWithChannel(msg, errorchan)
 			},
 			AddFunc: func(obj interface{}) {
 				secretObj, ok := obj.(*v1.Secret)
@@ -927,10 +940,7 @@ func (a *Agent) StreamHelmReleases(conn *websocket.Conn, namespace string, chart
 					Object:    helm_object,
 				}
 
-				if writeErr := conn.WriteJSON(msg); writeErr != nil {
-					errorchan <- writeErr
-					return
-				}
+				rw.WriteJSONWithChannel(msg, errorchan)
 			},
 			DeleteFunc: func(obj interface{}) {
 				secretObj, ok := obj.(*v1.Secret)
@@ -956,18 +966,14 @@ func (a *Agent) StreamHelmReleases(conn *websocket.Conn, namespace string, chart
 					Object:    helm_object,
 				}
 
-				if writeErr := conn.WriteJSON(msg); writeErr != nil {
-					errorchan <- writeErr
-					return
-				}
+				rw.WriteJSONWithChannel(msg, errorchan)
 			},
 		})
 
 		go func() {
 			// listens for websocket closing handshake
 			for {
-				if _, _, err := conn.ReadMessage(); err != nil {
-					conn.Close()
+				if _, _, err := rw.ReadMessage(); err != nil {
 					errorchan <- nil
 					return
 				}
@@ -1343,3 +1349,75 @@ func (a *Agent) CreateImagePullSecrets(
 
 	return res, nil
 }
+
+// helper that waits for pod to be ready
+func (a *Agent) waitForPod(pod *v1.Pod) (error, bool) {
+	var (
+		w   watch.Interface
+		err error
+		ok  bool
+	)
+	// immediately after creating a pod, the API may return a 404. heuristically 1
+	// second seems to be plenty.
+	watchRetries := 3
+	for i := 0; i < watchRetries; i++ {
+		selector := fields.OneTermEqualSelector("metadata.name", pod.Name).String()
+		w, err = a.Clientset.CoreV1().
+			Pods(pod.Namespace).
+			Watch(context.Background(), metav1.ListOptions{FieldSelector: selector})
+
+		if err == nil {
+			break
+		}
+		time.Sleep(time.Second)
+	}
+	if err != nil {
+		return err, false
+	}
+	defer w.Stop()
+	for {
+		select {
+		case <-time.After(time.Second * 30):
+			return goerrors.New("timed out waiting for pod"), false
+		case <-time.Tick(time.Second):
+			// poll every second in case we already missed the ready event while
+			// creating the listener.
+			pod, err = a.Clientset.CoreV1().
+				Pods(pod.Namespace).
+				Get(context.Background(), pod.Name, metav1.GetOptions{})
+
+			if err != nil && errors.IsNotFound(err) {
+				return IsNotFoundError, false
+			} else if err != nil {
+				return err, false
+			}
+
+			if isExited := isPodExited(pod); isExited || isPodReady(pod) {
+				return nil, isExited
+			}
+		case evt := <-w.ResultChan():
+			pod, ok = evt.Object.(*v1.Pod)
+			if !ok {
+				return fmt.Errorf("unexpected object type: %T", evt.Object), false
+			}
+			if isExited := isPodExited(pod); isExited || isPodReady(pod) {
+				return nil, isExited
+			}
+		}
+	}
+}
+
+func isPodReady(pod *v1.Pod) bool {
+	ready := false
+	conditions := pod.Status.Conditions
+	for i := range conditions {
+		if conditions[i].Type == v1.PodReady {
+			ready = pod.Status.Conditions[i].Status == v1.ConditionTrue
+		}
+	}
+	return ready
+}
+
+func isPodExited(pod *v1.Pod) bool {
+	return pod.Status.Phase == v1.PodSucceeded || pod.Status.Phase == v1.PodFailed
+}

+ 10 - 0
internal/kubernetes/provisioner/global_stream.go

@@ -402,6 +402,16 @@ func GlobalStreamListener(
 				if err != nil {
 					continue
 				}
+
+				if infra.Kind == types.InfraDOKS || infra.Kind == types.InfraGKE || infra.Kind == types.InfraEKS {
+					analyticsClient.Track(analytics.ClusterDestroyingSuccessTrack(
+						&analytics.ClusterDestroyingSuccessTrackOpts{
+							ClusterScopedTrackOpts: analytics.GetClusterScopedTrackOpts(infra.CreatedByUserID, infra.ProjectID, 0),
+							ClusterType:            infra.Kind,
+							InfraID:                infra.ID,
+						},
+					))
+				}
 			}
 
 			// acknowledge the message as read

+ 4 - 10
internal/kubernetes/provisioner/resource_stream.go

@@ -4,20 +4,17 @@ import (
 	"context"
 
 	redis "github.com/go-redis/redis/v8"
-	"github.com/gorilla/websocket"
+	"github.com/porter-dev/porter/api/server/shared/websocket"
 )
 
 // ResourceStream performs an XREAD operation on the given stream and outputs it to the given websocket conn.
-func ResourceStream(client *redis.Client, streamName string, conn *websocket.Conn) error {
+func ResourceStream(client *redis.Client, streamName string, rw *websocket.WebsocketSafeReadWriter) error {
 	errorchan := make(chan error)
 
 	go func() {
 		// listens for websocket closing handshake
 		for {
-			_, _, err := conn.ReadMessage()
-
-			if err != nil {
-				defer conn.Close()
+			if _, _, err := rw.ReadMessage(); err != nil {
 				errorchan <- nil
 				return
 			}
@@ -43,10 +40,7 @@ func ResourceStream(client *redis.Client, streamName string, conn *websocket.Con
 			messages := xstream[0].Messages
 			lastID = messages[len(messages)-1].ID
 
-			if writeErr := conn.WriteJSON(messages); writeErr != nil {
-				errorchan <- writeErr
-				return
-			}
+			rw.WriteJSONWithChannel(messages, errorchan)
 		}
 	}()