Justin Rhee 2 лет назад
Родитель
Сommit
2cb004d4c0
100 измененных файлов с 3556 добавлено и 1275 удалено
  1. 6 6
      .github/workflows/install_script.yml
  2. 88 0
      .github/workflows/internal_tooling_stack_porter-ui.yml
  3. 1 1
      .github/workflows/old_build-dev-cli.yaml
  4. 0 0
      .github/workflows/old_dev.yaml
  5. 1 1
      .github/workflows/old_production.yaml
  6. 0 0
      .github/workflows/old_staging.yaml
  7. 0 25
      .github/workflows/porter_tf_provisioner.yml
  8. 3 1
      .github/workflows/pr_push_checks.yaml
  9. 0 0
      .github/workflows/preview_env.yml
  10. 7 7
      .github/workflows/production.yml
  11. 25 0
      .github/workflows/tf_provisioner.yml
  12. 21 0
      api/client/stack.go
  13. 1 1
      api/server/handlers/api_contract/list.go
  14. 25 19
      api/server/handlers/cluster/install_agent.go
  15. 38 14
      api/server/handlers/environment/create_deployment_by_cluster.go
  16. 46 9
      api/server/handlers/environment/enable_pull_request.go
  17. 16 3
      api/server/handlers/environment/get_environment.go
  18. 33 2
      api/server/handlers/environment/update_environment_settings.go
  19. 37 8
      api/server/handlers/gitinstallation/get_porter_yaml.go
  20. 14 0
      api/server/handlers/gitinstallation/list_branches.go
  21. 87 0
      api/server/handlers/gitinstallation/workflow_log_runid.go
  22. 79 0
      api/server/handlers/gitinstallation/workflow_logs.go
  23. 1 1
      api/server/handlers/porter_app/analytics.go
  24. 484 0
      api/server/handlers/porter_app/create.go
  25. 196 0
      api/server/handlers/porter_app/create_and_update_events.go
  26. 1 1
      api/server/handlers/porter_app/create_secret_and_open_pr.go
  27. 1 1
      api/server/handlers/porter_app/delete.go
  28. 1 1
      api/server/handlers/porter_app/get.go
  29. 153 0
      api/server/handlers/porter_app/get_logs_within_time_range.go
  30. 1 1
      api/server/handlers/porter_app/list.go
  31. 233 0
      api/server/handlers/porter_app/list_events.go
  32. 1 1
      api/server/handlers/porter_app/parse.go
  33. 94 0
      api/server/handlers/porter_app/rollback.go
  34. 5 4
      api/server/handlers/project/get_usage.go
  35. 3 2
      api/server/handlers/project_integration/list_gitlab.go
  36. 0 3
      api/server/handlers/release/create.go
  37. 45 35
      api/server/handlers/release/upgrade_webhook.go
  38. 0 377
      api/server/handlers/stacks/create_porter_app.go
  39. 0 63
      api/server/handlers/stacks/get_porter_app_events.go
  40. 0 80
      api/server/handlers/stacks/list_porter_app_events.go
  41. 1 1
      api/server/router/base.go
  42. 1 1
      api/server/router/cluster.go
  43. 1 1
      api/server/router/cluster_integration.go
  44. 68 1
      api/server/router/git_installation.go
  45. 1 1
      api/server/router/helm_repo.go
  46. 1 1
      api/server/router/infra.go
  47. 1 1
      api/server/router/invite.go
  48. 21 0
      api/server/router/middleware/hydrate_trace.go
  49. 30 21
      api/server/router/middleware/usage.go
  50. 1 1
      api/server/router/namespace.go
  51. 1 1
      api/server/router/oauth_callback.go
  52. 82 23
      api/server/router/porter_app.go
  53. 1 1
      api/server/router/project.go
  54. 1 1
      api/server/router/project_integration.go
  55. 1 1
      api/server/router/project_oauth.go
  56. 1 1
      api/server/router/registry.go
  57. 1 1
      api/server/router/release.go
  58. 14 12
      api/server/router/router.go
  59. 1 1
      api/server/router/slack_integration.go
  60. 1 1
      api/server/router/status.go
  61. 1 1
      api/server/router/user.go
  62. 1 1
      api/server/router/v1/cluster.go
  63. 1 1
      api/server/router/v1/env_group.go
  64. 1 1
      api/server/router/v1/namespace.go
  65. 1 1
      api/server/router/v1/project.go
  66. 1 1
      api/server/router/v1/registry.go
  67. 1 1
      api/server/router/v1/release.go
  68. 1 1
      api/server/router/v1/stack.go
  69. 1 1
      api/server/shared/apitest/request.go
  70. 1 1
      api/server/shared/requestutils/url_param.go
  71. 1 1
      api/server/shared/requestutils/url_param_test.go
  72. 1 1
      api/server/shared/router/router.go
  73. 16 3
      api/types/incident.go
  74. 21 1
      api/types/porter_app.go
  75. 163 31
      cli/cmd/apply.go
  76. 4 3
      cli/cmd/preview/update_config_driver.go
  77. 9 0
      cmd/app/main.go
  78. 1 1
      cmd/dev/main.go
  79. 525 36
      dashboard/package-lock.json
  80. 2 0
      dashboard/package.json
  81. 3 0
      dashboard/src/assets/filter-outline-white.svg
  82. 1 1
      dashboard/src/assets/filter-outline.svg
  83. 3 0
      dashboard/src/assets/save-01.svg
  84. 5 1
      dashboard/src/components/LogQueryModeSelectionToggle.tsx
  85. 11 3
      dashboard/src/components/LogSearchBar.tsx
  86. 2 2
      dashboard/src/components/ProvisionerFlow.tsx
  87. 10 1
      dashboard/src/components/RadioFilter.tsx
  88. 24 0
      dashboard/src/components/date-time-picker/DateTimePicker.tsx
  89. 22 3
      dashboard/src/components/date-time-picker/react-datepicker.css
  90. 14 15
      dashboard/src/components/porter/Checkbox.tsx
  91. 16 7
      dashboard/src/components/porter/ConfirmOverlay.tsx
  92. 13 4
      dashboard/src/components/porter/Container.tsx
  93. 8 2
      dashboard/src/components/porter/Icon.tsx
  94. 33 15
      dashboard/src/components/porter/Link.tsx
  95. 9 4
      dashboard/src/components/porter/Text.tsx
  96. 17 14
      dashboard/src/components/repo-selector/DetectContentsList.tsx
  97. 91 83
      dashboard/src/main/home/add-on-dashboard/AddOnDashboard.tsx
  98. 2 2
      dashboard/src/main/home/add-on-dashboard/ConfigureTemplate.tsx
  99. 111 108
      dashboard/src/main/home/app-dashboard/AppDashboard.tsx
  100. 431 184
      dashboard/src/main/home/app-dashboard/expanded-app/ExpandedApp.tsx

+ 6 - 6
.github/workflows/porter_install_script.yml → .github/workflows/install_script.yml

@@ -1,8 +1,8 @@
 "on":
 "on":
   push:
   push:
-    branches:
-      - master
-name: Deploy to Porter
+    tags:
+      - production
+name: Deploy Install Script to Production
 jobs:
 jobs:
   porter-deploy:
   porter-deploy:
     runs-on: ubuntu-latest
     runs-on: ubuntu-latest
@@ -17,9 +17,9 @@ jobs:
         uses: porter-dev/porter-update-action@v0.1.0
         uses: porter-dev/porter-update-action@v0.1.0
         with:
         with:
           app: install-script
           app: install-script
-          cluster: "8"
+          cluster: "9"
           host: https://dashboard.internal-tools.getporter.dev
           host: https://dashboard.internal-tools.getporter.dev
           namespace: default
           namespace: default
-          project: "1"
+          project: "5"
           tag: ${{ steps.vars.outputs.sha_short }}
           tag: ${{ steps.vars.outputs.sha_short }}
-          token: ${{ secrets.PORTER_TOKEN_1 }}
+          token: ${{ secrets.PORTER_TOKEN_5 }}

+ 88 - 0
.github/workflows/internal_tooling_stack_porter-ui.yml

@@ -0,0 +1,88 @@
+"on":
+  push:
+    branches:
+      - master
+name: Deploy Porter to Internal Tooling
+jobs:
+  build-go:
+    runs-on: ubuntu-latest
+    steps:
+      - name: Checkout code
+        uses: actions/checkout@v3
+      - name: Setup Go Cache
+        uses: actions/cache@v3
+        with:
+          path: |
+            ~/.cache/go-build
+            ~/go/pkg/mod
+          key: porter-go-${{ hashFiles('**/go.sum') }}
+          restore-keys: porter-go-`
+      - name: Setup Go
+        uses: actions/setup-go@v4
+        with:
+          go-version-file: go.mod
+          cache: false
+      - name: Download Go Modules
+        run: go mod download
+      - name: Build Server Binary
+        run: go build -ldflags="-w -s -X 'main.Version=production'" -o ./bin/app ./cmd/app
+      - name: Build Migration Binary
+        run: go build -ldflags '-w -s' -o ./bin/migrate ./cmd/migrate
+      - name: Store Binaries
+        uses: actions/upload-artifact@v3
+        with:
+          name: go-binaries
+          path: bin/
+  build-npm:
+    runs-on: ubuntu-latest
+    steps:
+      - name: Checkout code
+        uses: actions/checkout@v3
+      - name: Setup Node
+        uses: actions/setup-node@v3
+        with:
+          node-version: 16
+      - name: Install NPM Dependencies
+        run: |
+          cd dashboard
+          npm i --legacy-peer-deps
+      - name: Run NPM Build
+        run: |
+          cd dashboard
+          npm run build
+      - name: Store NPM Static Files
+        uses: actions/upload-artifact@v3
+        with:
+          name: npm-static-files
+          path: dashboard/build/
+  porter-deploy:
+    runs-on: ubuntu-latest
+    needs: [build-go, build-npm]
+    steps:
+      - name: Checkout code
+        uses: actions/checkout@v3
+      - name: Get Go Binaries
+        uses: actions/download-artifact@v3
+        with:
+          name: go-binaries
+          path: bin/
+      - name: Get NPM static files
+        uses: actions/download-artifact@v3
+        with:
+          name: npm-static-files
+          path: build/
+      - name: Set Github tag
+        id: vars
+        run: echo "sha_short=$(git rev-parse --short HEAD)" >> $GITHUB_OUTPUT
+      - name: Deploy stack
+        timeout-minutes: 30
+        uses: porter-dev/porter-cli-action@v0.1.0
+        with:
+          command: apply -f nonexistant-porter.yaml
+        env:
+          PORTER_CLUSTER: "11"
+          PORTER_HOST: https://dashboard.internal-tools.getporter.dev
+          PORTER_PROJECT: "8"
+          PORTER_STACK_NAME: porter-ui
+          PORTER_TAG: ${{ steps.vars.outputs.sha_short }}
+          PORTER_TOKEN: ${{ secrets.PORTER_STACK_8_11 }}

+ 1 - 1
.github/workflows/build-dev-cli.yaml → .github/workflows/old_build-dev-cli.yaml

@@ -1,4 +1,4 @@
-name: Deploy to dev
+name: Build Dev CLI
 on:
 on:
   push:
   push:
     branches:
     branches:

+ 0 - 0
.github/workflows/dev.yaml → .github/workflows/old_dev.yaml


+ 1 - 1
.github/workflows/production.yaml → .github/workflows/old_production.yaml

@@ -1,4 +1,4 @@
-name: Deploy to production
+name: Deploy to old production
 on:
 on:
   push:
   push:
     tags:
     tags:

+ 0 - 0
.github/workflows/staging.yaml → .github/workflows/old_staging.yaml


+ 0 - 25
.github/workflows/porter_tf_provisioner.yml

@@ -1,25 +0,0 @@
-"on":
-  push:
-    branches:
-    - master
-name: Deploy to Porter
-jobs:
-  porter-deploy:
-    runs-on: ubuntu-latest
-    steps:
-    - name: Checkout code
-      uses: actions/checkout@v3
-    - name: Set Github tag
-      id: vars
-      run: echo "sha_short=$(git rev-parse --short HEAD)" >> $GITHUB_OUTPUT
-    - name: Update Porter App
-      timeout-minutes: 20
-      uses: porter-dev/porter-update-action@v0.1.0
-      with:
-        app: tf-provisioner
-        cluster: "8"
-        host: https://dashboard.internal-tools.getporter.dev
-        namespace: default
-        project: "1"
-        tag: ${{ steps.vars.outputs.sha_short }}
-        token: ${{ secrets.PORTER_TOKEN_1 }}

+ 3 - 1
.github/workflows/test-backend.yml → .github/workflows/pr_push_checks.yaml

@@ -1,4 +1,4 @@
-name: Backend CI
+name: PR Checks
 on:
 on:
   - pull_request
   - pull_request
 jobs:
 jobs:
@@ -24,5 +24,7 @@ jobs:
         with:
         with:
           go-version-file: go.mod
           go-version-file: go.mod
           cache: false
           cache: false
+      - name: Run Go vet
+        run: go vet ./${{ matrix.folder }}/...
       - name: Run Go tests
       - name: Run Go tests
         run: go test ./${{ matrix.folder }}/...
         run: go test ./${{ matrix.folder }}/...

+ 0 - 0
.github/workflows/porter_preview_env.yml → .github/workflows/preview_env.yml


+ 7 - 7
.github/workflows/porter_production.yml → .github/workflows/production.yml

@@ -1,8 +1,8 @@
 "on":
 "on":
   push:
   push:
-    branches:
-      - master
-name: Deploy to Porter
+    tags:
+      - production
+name: Deploy Porter to Production
 jobs:
 jobs:
   build-go:
   build-go:
     runs-on: ubuntu-latest
     runs-on: ubuntu-latest
@@ -78,10 +78,10 @@ jobs:
         timeout-minutes: 20
         timeout-minutes: 20
         uses: porter-dev/porter-update-action@v0.1.0
         uses: porter-dev/porter-update-action@v0.1.0
         with:
         with:
-          app: production
-          cluster: "8"
+          app: porter-ui
+          cluster: "9"
           host: https://dashboard.internal-tools.getporter.dev
           host: https://dashboard.internal-tools.getporter.dev
           namespace: default
           namespace: default
-          project: "1"
+          project: "5"
           tag: ${{ steps.vars.outputs.sha_short }}
           tag: ${{ steps.vars.outputs.sha_short }}
-          token: ${{ secrets.PORTER_TOKEN_1 }}
+          token: ${{ secrets.PORTER_TOKEN_5 }}

+ 25 - 0
.github/workflows/tf_provisioner.yml

@@ -0,0 +1,25 @@
+"on":
+  push:
+    tags:
+      - production
+name: Deploy TF Provisioner to Production
+jobs:
+  porter-deploy:
+    runs-on: ubuntu-latest
+    steps:
+      - name: Checkout code
+        uses: actions/checkout@v3
+      - name: Set Github tag
+        id: vars
+        run: echo "sha_short=$(git rev-parse --short HEAD)" >> $GITHUB_OUTPUT
+      - name: Update Porter App
+        timeout-minutes: 20
+        uses: porter-dev/porter-update-action@v0.1.0
+        with:
+          app: tf-provisioner
+          cluster: "9"
+          host: https://dashboard.internal-tools.getporter.dev
+          namespace: default
+          project: "5"
+          tag: ${{ steps.vars.outputs.sha_short }}
+          token: ${{ secrets.PORTER_TOKEN_5 }}

+ 21 - 0
api/client/stack.go

@@ -45,3 +45,24 @@ func (c *Client) CreatePorterApp(
 
 
 	return resp, err
 	return resp, err
 }
 }
+
+// CreateOrUpdatePorterAppEvent will create a porter app event if one does not exist, or else it will update the existing one if an ID is passed in the object
+func (c *Client) CreateOrUpdatePorterAppEvent(
+	ctx context.Context,
+	projectID, clusterID uint,
+	name string,
+	req *types.CreateOrUpdatePorterAppEventRequest,
+) (types.PorterAppEvent, error) {
+	resp := &types.PorterAppEvent{}
+
+	err := c.postRequest(
+		fmt.Sprintf(
+			"/projects/%d/clusters/%d/stacks/%s/events",
+			projectID, clusterID, name,
+		),
+		req,
+		resp,
+	)
+
+	return *resp, err
+}

+ 1 - 1
api/server/handlers/api_contract/list.go

@@ -5,7 +5,7 @@ import (
 	"net/http"
 	"net/http"
 	"strconv"
 	"strconv"
 
 
-	"github.com/go-chi/chi"
+	"github.com/go-chi/chi/v5"
 	"github.com/porter-dev/porter/api/server/handlers"
 	"github.com/porter-dev/porter/api/server/handlers"
 	"github.com/porter-dev/porter/api/server/shared"
 	"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/apierrors"

+ 25 - 19
api/server/handlers/cluster/install_agent.go

@@ -17,6 +17,7 @@ import (
 	"github.com/porter-dev/porter/internal/kubernetes"
 	"github.com/porter-dev/porter/internal/kubernetes"
 	"github.com/porter-dev/porter/internal/kubernetes/nodes"
 	"github.com/porter-dev/porter/internal/kubernetes/nodes"
 	"github.com/porter-dev/porter/internal/models"
 	"github.com/porter-dev/porter/internal/models"
+	"github.com/porter-dev/porter/internal/telemetry"
 	v1 "k8s.io/apimachinery/pkg/apis/meta/v1"
 	v1 "k8s.io/apimachinery/pkg/apis/meta/v1"
 )
 )
 
 
@@ -42,53 +43,61 @@ func NewInstallAgentHandler(
 }
 }
 
 
 func (c *InstallAgentHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
 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)
+	ctx, span := telemetry.NewSpan(r.Context(), "serve-install-agent-handler")
+	defer span.End()
+
+	proj, _ := ctx.Value(types.ProjectScope).(*models.Project)
+	user, _ := ctx.Value(types.UserScope).(*models.User)
+	cluster, _ := ctx.Value(types.ClusterScope).(*models.Cluster)
 
 
 	k8sAgent, err := c.GetAgent(r, cluster, "porter-agent-system")
 	k8sAgent, err := c.GetAgent(r, cluster, "porter-agent-system")
 	if err != nil {
 	if err != nil {
-		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		err = telemetry.Error(ctx, span, err, "failed to get kubernetes agent")
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
 		return
 		return
 	}
 	}
 
 
-	helmAgent, err := c.GetHelmAgent(r.Context(), r, cluster, "porter-agent-system")
+	helmAgent, err := c.GetHelmAgent(ctx, r, cluster, "porter-agent-system")
 	if err != nil {
 	if err != nil {
-		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		err = telemetry.Error(ctx, span, err, "failed to get helm agent")
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
 		return
 		return
 	}
 	}
 
 
 	err = checkAndDeleteOlderAgent(k8sAgent, helmAgent)
 	err = checkAndDeleteOlderAgent(k8sAgent, helmAgent)
-
 	if err != nil {
 	if err != nil {
-		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		err = telemetry.Error(ctx, span, err, "unable to delete older agent")
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
 		return
 		return
 	}
 	}
 
 
-	chart, err := loader.LoadChartPublic(context.Background(), c.Config().ServerConf.DefaultAddonHelmRepoURL, "porter-agent", "")
+	chart, err := loader.LoadChartPublic(ctx, c.Config().ServerConf.DefaultAddonHelmRepoURL, "porter-agent", "")
 	if err != nil {
 	if err != nil {
-		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		err = telemetry.Error(ctx, span, err, "failed load public porter-agent chart")
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
 		return
 		return
 	}
 	}
 
 
 	// create namespace if not exists
 	// create namespace if not exists
 	_, err = helmAgent.K8sAgent.CreateNamespace("porter-agent-system", nil)
 	_, err = helmAgent.K8sAgent.CreateNamespace("porter-agent-system", nil)
-
 	if err != nil {
 	if err != nil {
-		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		err = telemetry.Error(ctx, span, err, "failed to get create porter-agent-system namespace")
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
 		return
 		return
 	}
 	}
 
 
 	// add api token to values
 	// add api token to values
 	jwt, err := token.GetTokenForAPI(user.ID, proj.ID)
 	jwt, err := token.GetTokenForAPI(user.ID, proj.ID)
 	if err != nil {
 	if err != nil {
-		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		err = telemetry.Error(ctx, span, err, "failed to get porter-agent api token")
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
 		return
 		return
 	}
 	}
 
 
 	encoded, err := jwt.EncodeToken(c.Config().TokenConf)
 	encoded, err := jwt.EncodeToken(c.Config().TokenConf)
 	if err != nil {
 	if err != nil {
-		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		err = telemetry.Error(ctx, span, err, "failed to encode porter-agent api token")
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
 		return
 		return
 	}
 	}
 
 
@@ -140,12 +149,9 @@ func (c *InstallAgentHandler) ServeHTTP(w http.ResponseWriter, r *http.Request)
 	}
 	}
 
 
 	_, err = helmAgent.InstallChart(context.Background(), conf, c.Config().DOConf, c.Config().ServerConf.DisablePullSecretsInjection)
 	_, err = helmAgent.InstallChart(context.Background(), conf, c.Config().DOConf, c.Config().ServerConf.DisablePullSecretsInjection)
-
 	if err != nil {
 	if err != nil {
-		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(
-			fmt.Errorf("error installing porter-agent: %w", err), http.StatusBadRequest,
-		))
-
+		err = telemetry.Error(ctx, span, err, "error installing porter-agent")
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
 		return
 		return
 	}
 	}
 
 

+ 38 - 14
api/server/handlers/environment/create_deployment_by_cluster.go

@@ -1,11 +1,12 @@
 package environment
 package environment
 
 
 import (
 import (
-	"context"
 	"errors"
 	"errors"
 	"fmt"
 	"fmt"
 	"net/http"
 	"net/http"
 
 
+	"github.com/porter-dev/porter/internal/telemetry"
+
 	"github.com/porter-dev/porter/api/server/authz"
 	"github.com/porter-dev/porter/api/server/authz"
 	"github.com/porter-dev/porter/api/server/handlers"
 	"github.com/porter-dev/porter/api/server/handlers"
 	"github.com/porter-dev/porter/api/server/shared"
 	"github.com/porter-dev/porter/api/server/shared"
@@ -33,21 +34,43 @@ func NewCreateDeploymentByClusterHandler(
 }
 }
 
 
 func (c *CreateDeploymentByClusterHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
 func (c *CreateDeploymentByClusterHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
-	project, _ := r.Context().Value(types.ProjectScope).(*models.Project)
-	cluster, _ := r.Context().Value(types.ClusterScope).(*models.Cluster)
+	ctx, span := telemetry.NewSpan(r.Context(), "serve-create-deployment-by-cluster")
+	defer span.End()
+
+	project, _ := ctx.Value(types.ProjectScope).(*models.Project)
+	cluster, _ := ctx.Value(types.ClusterScope).(*models.Cluster)
+
+	telemetry.WithAttributes(span,
+		telemetry.AttributeKV{Key: "project-id", Value: project.ID},
+		telemetry.AttributeKV{Key: "cluster-id", Value: cluster.ID},
+	)
 
 
 	request := &types.CreateDeploymentRequest{}
 	request := &types.CreateDeploymentRequest{}
 
 
 	if ok := c.DecodeAndValidate(w, r, request); !ok {
 	if ok := c.DecodeAndValidate(w, r, request); !ok {
+		err := telemetry.Error(ctx, span, nil, "could not decode and validate request")
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusBadRequest))
 		return
 		return
 	}
 	}
 
 
+	telemetry.WithAttributes(span,
+		telemetry.AttributeKV{Key: "repo-owner", Value: request.RepoOwner},
+		telemetry.AttributeKV{Key: "repo-name", Value: request.RepoName},
+		telemetry.AttributeKV{Key: "namespace", Value: request.Namespace},
+		telemetry.AttributeKV{Key: "pull-request-id", Value: request.PullRequestID},
+		telemetry.AttributeKV{Key: "pr-name", Value: request.GitHubMetadata.PRName},
+		telemetry.AttributeKV{Key: "commit-sha", Value: request.GitHubMetadata.CommitSHA},
+		telemetry.AttributeKV{Key: "pr-branch-from", Value: request.GitHubMetadata.PRBranchFrom},
+		telemetry.AttributeKV{Key: "pr-branch-into", Value: request.GitHubMetadata.PRBranchInto},
+	)
+
 	// read the environment to get the environment id
 	// read the environment to get the environment id
 	env, err := c.Repo().Environment().ReadEnvironmentByOwnerRepoName(
 	env, err := c.Repo().Environment().ReadEnvironmentByOwnerRepoName(
 		project.ID, cluster.ID, request.RepoOwner, request.RepoName,
 		project.ID, cluster.ID, request.RepoOwner, request.RepoName,
 	)
 	)
-
 	if err != nil {
 	if err != nil {
+		err = telemetry.Error(ctx, span, err, "error reading environment by owner repo name")
+
 		if errors.Is(err, gorm.ErrRecordNotFound) {
 		if errors.Is(err, gorm.ErrRecordNotFound) {
 			c.HandleAPIError(w, r, apierrors.NewErrNotFound(
 			c.HandleAPIError(w, r, apierrors.NewErrNotFound(
 				fmt.Errorf("error creating deployment: %w", errEnvironmentNotFound)),
 				fmt.Errorf("error creating deployment: %w", errEnvironmentNotFound)),
@@ -61,21 +84,24 @@ func (c *CreateDeploymentByClusterHandler) ServeHTTP(w http.ResponseWriter, r *h
 
 
 	// create deployment on GitHub API
 	// create deployment on GitHub API
 	client, err := getGithubClientFromEnvironment(c.Config(), env)
 	client, err := getGithubClientFromEnvironment(c.Config(), env)
-
 	if err != nil {
 	if err != nil {
+		err = telemetry.Error(ctx, span, err, "error getting github client from environment")
 		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
 		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
 		return
 		return
 	}
 	}
 
 
 	// add a check for Github PR status
 	// add a check for Github PR status
 	prClosed, err := isGithubPRClosed(client, request.RepoOwner, request.RepoName, int(request.PullRequestID))
 	prClosed, err := isGithubPRClosed(client, request.RepoOwner, request.RepoName, int(request.PullRequestID))
-
 	if err != nil {
 	if err != nil {
+		err = telemetry.Error(ctx, span, err, "error checking if github pr is closed")
 		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusConflict))
 		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusConflict))
 		return
 		return
 	}
 	}
 
 
+	telemetry.WithAttributes(span, telemetry.AttributeKV{Key: "pr-closed", Value: prClosed})
+
 	if prClosed {
 	if prClosed {
+		telemetry.WithAttributes(span, telemetry.AttributeKV{Key: "unsuccessful-exit-reason", Value: "pr is closed"})
 		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(
 		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(
 			fmt.Errorf("attempting to create deployment for a closed github PR"), http.StatusConflict,
 			fmt.Errorf("attempting to create deployment for a closed github PR"), http.StatusConflict,
 		))
 		))
@@ -83,8 +109,8 @@ func (c *CreateDeploymentByClusterHandler) ServeHTTP(w http.ResponseWriter, r *h
 	}
 	}
 
 
 	ghDeployment, err := createGithubDeployment(client, env, request.PRBranchFrom, request.ActionID)
 	ghDeployment, err := createGithubDeployment(client, env, request.PRBranchFrom, request.ActionID)
-
 	if err != nil {
 	if err != nil {
+		err = telemetry.Error(ctx, span, err, "error creating github deployment object")
 		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusConflict))
 		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusConflict))
 		return
 		return
 	}
 	}
@@ -103,20 +129,18 @@ func (c *CreateDeploymentByClusterHandler) ServeHTTP(w http.ResponseWriter, r *h
 		PRBranchFrom:   request.GitHubMetadata.PRBranchFrom,
 		PRBranchFrom:   request.GitHubMetadata.PRBranchFrom,
 		PRBranchInto:   request.GitHubMetadata.PRBranchInto,
 		PRBranchInto:   request.GitHubMetadata.PRBranchInto,
 	})
 	})
-
 	if err != nil {
 	if err != nil {
+		err = telemetry.Error(ctx, span, err, "error creating github deployment object")
 		// try to delete the GitHub deployment
 		// try to delete the GitHub deployment
-		_, err = client.Repositories.DeleteDeployment(
-			context.Background(),
+		_, deleteErr := client.Repositories.DeleteDeployment(
+			ctx,
 			env.GitRepoOwner,
 			env.GitRepoOwner,
 			env.GitRepoName,
 			env.GitRepoName,
 			ghDeployment.GetID(),
 			ghDeployment.GetID(),
 		)
 		)
 
 
-		if err != nil {
-			c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(fmt.Errorf("%v: %w", errGithubAPI, err),
-				http.StatusConflict))
-			return
+		if deleteErr != nil {
+			telemetry.WithAttributes(span, telemetry.AttributeKV{Key: "delete-err", Value: deleteErr.Error()})
 		}
 		}
 
 
 		c.HandleAPIError(w, r, apierrors.NewErrInternal(fmt.Errorf("error creating deployment: %w", err)))
 		c.HandleAPIError(w, r, apierrors.NewErrInternal(fmt.Errorf("error creating deployment: %w", err)))

+ 46 - 9
api/server/handlers/environment/enable_pull_request.go

@@ -14,6 +14,7 @@ import (
 	"github.com/porter-dev/porter/api/server/shared/config"
 	"github.com/porter-dev/porter/api/server/shared/config"
 	"github.com/porter-dev/porter/api/types"
 	"github.com/porter-dev/porter/api/types"
 	"github.com/porter-dev/porter/internal/models"
 	"github.com/porter-dev/porter/internal/models"
+	"github.com/porter-dev/porter/internal/telemetry"
 	"gorm.io/gorm"
 	"gorm.io/gorm"
 )
 )
 
 
@@ -34,17 +35,39 @@ func NewEnablePullRequestHandler(
 }
 }
 
 
 func (c *EnablePullRequestHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
 func (c *EnablePullRequestHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
-	project, _ := r.Context().Value(types.ProjectScope).(*models.Project)
-	cluster, _ := r.Context().Value(types.ClusterScope).(*models.Cluster)
+	ctx, span := telemetry.NewSpan(r.Context(), "serve-enable-pull-request")
+	defer span.End()
+
+	project, _ := ctx.Value(types.ProjectScope).(*models.Project)
+	cluster, _ := ctx.Value(types.ClusterScope).(*models.Cluster)
+
+	telemetry.WithAttributes(span,
+		telemetry.AttributeKV{Key: "project-id", Value: project.ID},
+		telemetry.AttributeKV{Key: "cluster-id", Value: cluster.ID},
+	)
 
 
 	request := &types.PullRequest{}
 	request := &types.PullRequest{}
 
 
 	if ok := c.DecodeAndValidate(w, r, request); !ok {
 	if ok := c.DecodeAndValidate(w, r, request); !ok {
+		_ = telemetry.Error(ctx, span, nil, "could not decode and validate request")
 		return
 		return
 	}
 	}
 
 
+	telemetry.WithAttributes(span,
+		telemetry.AttributeKV{Key: "title", Value: request.Title},
+		telemetry.AttributeKV{Key: "number", Value: request.Number},
+		telemetry.AttributeKV{Key: "repo-ower", Value: request.RepoOwner},
+		telemetry.AttributeKV{Key: "repo-name", Value: request.RepoName},
+		telemetry.AttributeKV{Key: "branch-from", Value: request.BranchFrom},
+		telemetry.AttributeKV{Key: "branch-into", Value: request.BranchInto},
+		telemetry.AttributeKV{Key: "created-at", Value: request.CreatedAt},
+		telemetry.AttributeKV{Key: "updated-at", Value: request.UpdatedAt},
+	)
+
 	env, err := c.Repo().Environment().ReadEnvironmentByOwnerRepoName(project.ID, cluster.ID, request.RepoOwner, request.RepoName)
 	env, err := c.Repo().Environment().ReadEnvironmentByOwnerRepoName(project.ID, cluster.ID, request.RepoOwner, request.RepoName)
 	if err != nil {
 	if err != nil {
+		err = telemetry.Error(ctx, span, err, "error reading environment by owner repo name")
+
 		if errors.Is(err, gorm.ErrRecordNotFound) {
 		if errors.Is(err, gorm.ErrRecordNotFound) {
 			c.HandleAPIError(w, r, apierrors.NewErrNotFound(fmt.Errorf("environment not found in cluster and project")))
 			c.HandleAPIError(w, r, apierrors.NewErrNotFound(fmt.Errorf("environment not found in cluster and project")))
 			return
 			return
@@ -67,6 +90,8 @@ func (c *EnablePullRequestHandler) ServeHTTP(w http.ResponseWriter, r *http.Requ
 		}
 		}
 
 
 		if !found {
 		if !found {
+			telemetry.WithAttributes(span, telemetry.AttributeKV{Key: "unsuccessful-exit-reason", Value: "cannot find branch-into in git repo branches"})
+
 			c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(
 			c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(
 				fmt.Errorf("base branch '%s' is not enabled for this preview environment, please enable it "+
 				fmt.Errorf("base branch '%s' is not enabled for this preview environment, please enable it "+
 					"in the settings page to continue", request.BranchInto), http.StatusBadRequest,
 					"in the settings page to continue", request.BranchInto), http.StatusBadRequest,
@@ -84,6 +109,8 @@ func (c *EnablePullRequestHandler) ServeHTTP(w http.ResponseWriter, r *http.Requ
 		}
 		}
 
 
 		if found {
 		if found {
+			telemetry.WithAttributes(span, telemetry.AttributeKV{Key: "unsuccessful-exit-reason", Value: "cannot find branch-from in git deploy branches"})
+
 			c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(
 			c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(
 				fmt.Errorf("head branch '%s' is enabled for branch deploys for this preview environment, "+
 				fmt.Errorf("head branch '%s' is enabled for branch deploys for this preview environment, "+
 					"please disable it in the settings page to continue", request.BranchInto), http.StatusBadRequest,
 					"please disable it in the settings page to continue", request.BranchInto), http.StatusBadRequest,
@@ -94,26 +121,32 @@ func (c *EnablePullRequestHandler) ServeHTTP(w http.ResponseWriter, r *http.Requ
 
 
 	client, err := getGithubClientFromEnvironment(c.Config(), env)
 	client, err := getGithubClientFromEnvironment(c.Config(), env)
 	if err != nil {
 	if err != nil {
+		err = telemetry.Error(ctx, span, err, "error getting github client from environment")
+
 		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
 		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
 		return
 		return
 	}
 	}
 
 
 	// add an extra check that the installation has permission to read this pull request
 	// add an extra check that the installation has permission to read this pull request
-	pr, _, err := client.PullRequests.Get(r.Context(), env.GitRepoOwner, env.GitRepoName, int(request.Number))
+	pr, _, err := client.PullRequests.Get(ctx, env.GitRepoOwner, env.GitRepoName, int(request.Number))
 	if err != nil {
 	if err != nil {
+		_ = telemetry.Error(ctx, span, err, "error getting pull request")
+
 		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(fmt.Errorf("%v: %w", errGithubAPI, err),
 		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(fmt.Errorf("%v: %w", errGithubAPI, err),
 			http.StatusConflict))
 			http.StatusConflict))
 		return
 		return
 	}
 	}
 
 
 	if pr.GetState() == "closed" {
 	if pr.GetState() == "closed" {
+		telemetry.WithAttributes(span, telemetry.AttributeKV{Key: "unsuccessful-exit-reason", Value: "pr is closed"})
+
 		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(fmt.Errorf("cannot enable deployment for closed PR"),
 		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(fmt.Errorf("cannot enable deployment for closed PR"),
 			http.StatusConflict))
 			http.StatusConflict))
 		return
 		return
 	}
 	}
 
 
 	ghResp, err := client.Actions.CreateWorkflowDispatchEventByFileName(
 	ghResp, err := client.Actions.CreateWorkflowDispatchEventByFileName(
-		r.Context(), env.GitRepoOwner, env.GitRepoName, fmt.Sprintf("porter_%s_env.yml", env.Name),
+		ctx, env.GitRepoOwner, env.GitRepoName, fmt.Sprintf("porter_%s_env.yml", env.Name),
 		github.CreateWorkflowDispatchEventRequest{
 		github.CreateWorkflowDispatchEventRequest{
 			Ref: request.BranchFrom,
 			Ref: request.BranchFrom,
 			Inputs: map[string]interface{}{
 			Inputs: map[string]interface{}{
@@ -124,9 +157,16 @@ func (c *EnablePullRequestHandler) ServeHTTP(w http.ResponseWriter, r *http.Requ
 			},
 			},
 		},
 		},
 	)
 	)
+	if err != nil {
+		err = telemetry.Error(ctx, span, err, "error creating workflow dispatch event")
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
 
 
 	if ghResp != nil {
 	if ghResp != nil {
+		telemetry.WithAttributes(span, telemetry.AttributeKV{Key: "github-status-code", Value: ghResp.StatusCode})
 		if ghResp.StatusCode == 404 {
 		if ghResp.StatusCode == 404 {
+			telemetry.WithAttributes(span, telemetry.AttributeKV{Key: "unsuccessful-exit-reason", Value: "bad github status code"})
 			c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(
 			c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(
 				fmt.Errorf(
 				fmt.Errorf(
 					"please make sure the preview environment workflow files are present in PR branch %s and are up to"+
 					"please make sure the preview environment workflow files are present in PR branch %s and are up to"+
@@ -135,6 +175,7 @@ func (c *EnablePullRequestHandler) ServeHTTP(w http.ResponseWriter, r *http.Requ
 			)
 			)
 			return
 			return
 		} else if ghResp.StatusCode == 422 {
 		} else if ghResp.StatusCode == 422 {
+			telemetry.WithAttributes(span, telemetry.AttributeKV{Key: "unsuccessful-exit-reason", Value: "bad github status code"})
 			c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(
 			c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(
 				fmt.Errorf(
 				fmt.Errorf(
 					"please make sure the workflow files in PR branch %s are up to date with the default branch",
 					"please make sure the workflow files in PR branch %s are up to date with the default branch",
@@ -145,11 +186,6 @@ func (c *EnablePullRequestHandler) ServeHTTP(w http.ResponseWriter, r *http.Requ
 		}
 		}
 	}
 	}
 
 
-	if err != nil {
-		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
-		return
-	}
-
 	// create the deployment
 	// create the deployment
 	depl, err := c.Repo().Environment().CreateDeployment(&models.Deployment{
 	depl, err := c.Repo().Environment().CreateDeployment(&models.Deployment{
 		EnvironmentID: env.ID,
 		EnvironmentID: env.ID,
@@ -163,6 +199,7 @@ func (c *EnablePullRequestHandler) ServeHTTP(w http.ResponseWriter, r *http.Requ
 		PRBranchInto:  request.BranchInto,
 		PRBranchInto:  request.BranchInto,
 	})
 	})
 	if err != nil {
 	if err != nil {
+		err = telemetry.Error(ctx, span, err, "error creating deployment in repo")
 		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
 		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
 		return
 		return
 	}
 	}

+ 16 - 3
api/server/handlers/environment/get_environment.go

@@ -5,6 +5,8 @@ import (
 	"fmt"
 	"fmt"
 	"net/http"
 	"net/http"
 
 
+	"github.com/porter-dev/porter/internal/telemetry"
+
 	"github.com/porter-dev/porter/api/server/handlers"
 	"github.com/porter-dev/porter/api/server/handlers"
 	"github.com/porter-dev/porter/api/server/shared"
 	"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/apierrors"
@@ -29,18 +31,29 @@ func NewGetEnvironmentHandler(
 }
 }
 
 
 func (c *GetEnvironmentHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
 func (c *GetEnvironmentHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
-	project, _ := r.Context().Value(types.ProjectScope).(*models.Project)
-	cluster, _ := r.Context().Value(types.ClusterScope).(*models.Cluster)
+	ctx, span := telemetry.NewSpan(r.Context(), "serve-get-environment")
+	defer span.End()
 
 
-	envID, reqErr := requestutils.GetURLParamUint(r, "environment_id")
+	project, _ := ctx.Value(types.ProjectScope).(*models.Project)
+	cluster, _ := ctx.Value(types.ClusterScope).(*models.Cluster)
+
+	telemetry.WithAttributes(span,
+		telemetry.AttributeKV{Key: "project-id", Value: project.ID},
+		telemetry.AttributeKV{Key: "cluster-id", Value: cluster.ID},
+	)
 
 
+	envID, reqErr := requestutils.GetURLParamUint(r, "environment_id")
 	if reqErr != nil {
 	if reqErr != nil {
+		_ = telemetry.Error(ctx, span, reqErr, "could not get environment id from url")
 		c.HandleAPIError(w, r, reqErr)
 		c.HandleAPIError(w, r, reqErr)
 		return
 		return
 	}
 	}
 
 
+	telemetry.WithAttributes(span, telemetry.AttributeKV{Key: "environment-id", Value: envID})
+
 	env, err := c.Repo().Environment().ReadEnvironmentByID(project.ID, cluster.ID, envID)
 	env, err := c.Repo().Environment().ReadEnvironmentByID(project.ID, cluster.ID, envID)
 	if err != nil {
 	if err != nil {
+		_ = telemetry.Error(ctx, span, err, "could not read environment by id")
 		if errors.Is(err, gorm.ErrRecordNotFound) {
 		if errors.Is(err, gorm.ErrRecordNotFound) {
 			c.HandleAPIError(w, r, apierrors.NewErrNotFound(fmt.Errorf("no such environment with ID: %d", envID)))
 			c.HandleAPIError(w, r, apierrors.NewErrNotFound(fmt.Errorf("no such environment with ID: %d", envID)))
 			return
 			return

+ 33 - 2
api/server/handlers/environment/update_environment_settings.go

@@ -8,6 +8,8 @@ import (
 	"reflect"
 	"reflect"
 	"strings"
 	"strings"
 
 
+	"github.com/porter-dev/porter/internal/telemetry"
+
 	"github.com/porter-dev/porter/api/server/authz"
 	"github.com/porter-dev/porter/api/server/authz"
 	"github.com/porter-dev/porter/api/server/handlers"
 	"github.com/porter-dev/porter/api/server/handlers"
 	"github.com/porter-dev/porter/api/server/shared"
 	"github.com/porter-dev/porter/api/server/shared"
@@ -36,24 +38,44 @@ func NewUpdateEnvironmentSettingsHandler(
 }
 }
 
 
 func (c *UpdateEnvironmentSettingsHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
 func (c *UpdateEnvironmentSettingsHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
-	project, _ := r.Context().Value(types.ProjectScope).(*models.Project)
-	cluster, _ := r.Context().Value(types.ClusterScope).(*models.Cluster)
+	ctx, span := telemetry.NewSpan(r.Context(), "serve-update-environment-settings")
+	defer span.End()
+
+	project, _ := ctx.Value(types.ProjectScope).(*models.Project)
+	cluster, _ := ctx.Value(types.ClusterScope).(*models.Cluster)
+
+	telemetry.WithAttributes(span,
+		telemetry.AttributeKV{Key: "project-id", Value: project.ID},
+		telemetry.AttributeKV{Key: "cluster-id", Value: cluster.ID},
+	)
 
 
 	envID, reqErr := requestutils.GetURLParamUint(r, "environment_id")
 	envID, reqErr := requestutils.GetURLParamUint(r, "environment_id")
 
 
 	if reqErr != nil {
 	if reqErr != nil {
+		_ = telemetry.Error(ctx, span, reqErr, "could not get environment id from url")
 		c.HandleAPIError(w, r, reqErr)
 		c.HandleAPIError(w, r, reqErr)
 		return
 		return
 	}
 	}
 
 
+	telemetry.WithAttributes(span, telemetry.AttributeKV{Key: "environment-id", Value: envID})
+
 	request := &types.UpdateEnvironmentSettingsRequest{}
 	request := &types.UpdateEnvironmentSettingsRequest{}
 
 
 	if ok := c.DecodeAndValidate(w, r, request); !ok {
 	if ok := c.DecodeAndValidate(w, r, request); !ok {
+		_ = telemetry.Error(ctx, span, nil, "could not decode and validate request")
 		return
 		return
 	}
 	}
 
 
+	telemetry.WithAttributes(span,
+		telemetry.AttributeKV{Key: "mode", Value: request.Mode},
+		telemetry.AttributeKV{Key: "git-repo-branches", Value: request.GitRepoBranches},
+		telemetry.AttributeKV{Key: "git-deploy-branches", Value: request.GitDeployBranches},
+	)
+
 	env, err := c.Repo().Environment().ReadEnvironmentByID(project.ID, cluster.ID, envID)
 	env, err := c.Repo().Environment().ReadEnvironmentByID(project.ID, cluster.ID, envID)
 	if err != nil {
 	if err != nil {
+		err = telemetry.Error(ctx, span, err, "could not read environment by id")
+
 		if errors.Is(err, gorm.ErrRecordNotFound) {
 		if errors.Is(err, gorm.ErrRecordNotFound) {
 			c.HandleAPIError(w, r, apierrors.NewErrNotFound(fmt.Errorf("no such environment with ID: %d", envID)))
 			c.HandleAPIError(w, r, apierrors.NewErrNotFound(fmt.Errorf("no such environment with ID: %d", envID)))
 			return
 			return
@@ -91,10 +113,13 @@ func (c *UpdateEnvironmentSettingsHandler) ServeHTTP(w http.ResponseWriter, r *h
 
 
 	changed = !reflect.DeepEqual(env.ToEnvironmentType().GitDeployBranches, newBranches)
 	changed = !reflect.DeepEqual(env.ToEnvironmentType().GitDeployBranches, newBranches)
 
 
+	telemetry.WithAttributes(span, telemetry.AttributeKV{Key: "changed", Value: changed})
+
 	if changed {
 	if changed {
 		// let us check if the webhook has access to the "push" event
 		// let us check if the webhook has access to the "push" event
 		client, err := getGithubClientFromEnvironment(c.Config(), env)
 		client, err := getGithubClientFromEnvironment(c.Config(), env)
 		if err != nil {
 		if err != nil {
+			err = telemetry.Error(ctx, span, err, "could not get github client")
 			c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
 			c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
 			return
 			return
 		}
 		}
@@ -103,6 +128,7 @@ func (c *UpdateEnvironmentSettingsHandler) ServeHTTP(w http.ResponseWriter, r *h
 			context.Background(), env.GitRepoOwner, env.GitRepoName, env.GithubWebhookID,
 			context.Background(), env.GitRepoOwner, env.GitRepoName, env.GithubWebhookID,
 		)
 		)
 		if err != nil {
 		if err != nil {
+			err = telemetry.Error(ctx, span, err, "could not get hook")
 			c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
 			c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
 			return
 			return
 		}
 		}
@@ -116,6 +142,8 @@ func (c *UpdateEnvironmentSettingsHandler) ServeHTTP(w http.ResponseWriter, r *h
 			}
 			}
 		}
 		}
 
 
+		telemetry.WithAttributes(span, telemetry.AttributeKV{Key: "found", Value: found})
+
 		if !found {
 		if !found {
 			hook.Events = append(hook.Events, "push")
 			hook.Events = append(hook.Events, "push")
 
 
@@ -123,6 +151,7 @@ func (c *UpdateEnvironmentSettingsHandler) ServeHTTP(w http.ResponseWriter, r *h
 				context.Background(), env.GitRepoOwner, env.GitRepoName, env.GithubWebhookID, hook,
 				context.Background(), env.GitRepoOwner, env.GitRepoName, env.GithubWebhookID, hook,
 			)
 			)
 			if err != nil {
 			if err != nil {
+				err = telemetry.Error(ctx, span, err, "could not edit hook")
 				c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
 				c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
 				return
 				return
 			}
 			}
@@ -140,6 +169,7 @@ func (c *UpdateEnvironmentSettingsHandler) ServeHTTP(w http.ResponseWriter, r *h
 					errString += ": " + e.Error()
 					errString += ": " + e.Error()
 				}
 				}
 
 
+				_ = telemetry.Error(ctx, span, errors.New(errString), "could not auto deploy branch")
 				c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(
 				c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(
 					fmt.Errorf("error auto deploying preview branches: %s", errString), http.StatusConflict),
 					fmt.Errorf("error auto deploying preview branches: %s", errString), http.StatusConflict),
 				)
 				)
@@ -178,6 +208,7 @@ func (c *UpdateEnvironmentSettingsHandler) ServeHTTP(w http.ResponseWriter, r *h
 		env, err = c.Repo().Environment().UpdateEnvironment(env)
 		env, err = c.Repo().Environment().UpdateEnvironment(env)
 
 
 		if err != nil {
 		if err != nil {
+			err = telemetry.Error(ctx, span, err, "could not update environment")
 			c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
 			c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
 			return
 			return
 		}
 		}

+ 37 - 8
api/server/handlers/gitinstallation/get_porter_yaml.go

@@ -8,11 +8,14 @@ import (
 	"github.com/google/go-github/v41/github"
 	"github.com/google/go-github/v41/github"
 	"github.com/porter-dev/porter/api/server/authz"
 	"github.com/porter-dev/porter/api/server/authz"
 	"github.com/porter-dev/porter/api/server/handlers"
 	"github.com/porter-dev/porter/api/server/handlers"
+	"github.com/porter-dev/porter/api/server/handlers/porter_app"
 	"github.com/porter-dev/porter/api/server/shared"
 	"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/apierrors"
 	"github.com/porter-dev/porter/api/server/shared/commonutils"
 	"github.com/porter-dev/porter/api/server/shared/commonutils"
 	"github.com/porter-dev/porter/api/server/shared/config"
 	"github.com/porter-dev/porter/api/server/shared/config"
 	"github.com/porter-dev/porter/api/types"
 	"github.com/porter-dev/porter/api/types"
+	"github.com/porter-dev/porter/internal/telemetry"
+	"gopkg.in/yaml.v2"
 )
 )
 
 
 type GithubGetPorterYamlHandler struct {
 type GithubGetPorterYamlHandler struct {
@@ -31,29 +34,36 @@ func NewGithubGetPorterYamlHandler(
 }
 }
 
 
 func (c *GithubGetPorterYamlHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
 func (c *GithubGetPorterYamlHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	ctx, span := telemetry.NewSpan(r.Context(), "serve-get-porter-yaml")
+	defer span.End()
 	request := &types.GetPorterYamlRequest{}
 	request := &types.GetPorterYamlRequest{}
-
 	ok := c.DecodeAndValidate(w, r, request)
 	ok := c.DecodeAndValidate(w, r, request)
-
 	if !ok {
 	if !ok {
+		err := telemetry.Error(ctx, span, nil, "invalid request body")
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusBadRequest))
 		return
 		return
 	}
 	}
 
 
-	owner, name, ok := commonutils.GetOwnerAndNameParams(c, w, r)
+	telemetry.WithAttributes(span, telemetry.AttributeKV{Key: "path", Value: request.Path})
 
 
+	owner, name, ok := commonutils.GetOwnerAndNameParams(c, w, r)
 	if !ok {
 	if !ok {
+		err := telemetry.Error(ctx, span, nil, "unable to get owner and name")
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusBadRequest))
 		return
 		return
 	}
 	}
 
 
 	branch, ok := commonutils.GetBranchParam(c, w, r)
 	branch, ok := commonutils.GetBranchParam(c, w, r)
-
 	if !ok {
 	if !ok {
+		err := telemetry.Error(ctx, span, nil, "unable to get branch")
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusBadRequest))
 		return
 		return
 	}
 	}
 
 
 	client, err := GetGithubAppClientFromRequest(c.Config(), r)
 	client, err := GetGithubAppClientFromRequest(c.Config(), r)
 	if err != nil {
 	if err != nil {
-		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		err = telemetry.Error(ctx, span, err, "unable to get github app client")
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
 		return
 		return
 	}
 	}
 
 
@@ -67,16 +77,35 @@ func (c *GithubGetPorterYamlHandler) ServeHTTP(w http.ResponseWriter, r *http.Re
 		},
 		},
 	)
 	)
 	if err != nil {
 	if err != nil {
-		http.NotFound(w, r)
+		err = telemetry.Error(ctx, span, err, "unable to get contents")
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
 		return
 		return
 	}
 	}
 
 
 	fileData, err := resp.GetContent()
 	fileData, err := resp.GetContent()
 	if err != nil {
 	if err != nil {
-		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		err = telemetry.Error(ctx, span, err, "unable to get file data")
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
 		return
 		return
 	}
 	}
-	data := b64.StdEncoding.EncodeToString([]byte(fileData))
 
 
+	parsed := &porter_app.PorterStackYAML{}
+	err = yaml.Unmarshal([]byte(fileData), parsed)
+	if err != nil {
+		err = telemetry.Error(ctx, span, err, "invalid porter yaml format")
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusBadRequest))
+		return
+	}
+	// backwards compatibility so that old porter yamls are no longer valid
+	if parsed.Version != nil {
+		version := *parsed.Version
+		if version != "v1stack" {
+			err = telemetry.Error(ctx, span, nil, "porter YAML version is not supported")
+			c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusBadRequest))
+			return
+		}
+	}
+
+	data := b64.StdEncoding.EncodeToString([]byte(fileData))
 	c.WriteResult(w, r, data)
 	c.WriteResult(w, r, data)
 }
 }

+ 14 - 0
api/server/handlers/gitinstallation/list_branches.go

@@ -5,6 +5,8 @@ import (
 	"net/http"
 	"net/http"
 	"sync"
 	"sync"
 
 
+	"github.com/porter-dev/porter/internal/telemetry"
+
 	"github.com/google/go-github/v41/github"
 	"github.com/google/go-github/v41/github"
 	"github.com/porter-dev/porter/api/server/authz"
 	"github.com/porter-dev/porter/api/server/authz"
 	"github.com/porter-dev/porter/api/server/handlers"
 	"github.com/porter-dev/porter/api/server/handlers"
@@ -30,14 +32,24 @@ func NewGithubListBranchesHandler(
 }
 }
 
 
 func (c *GithubListBranchesHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
 func (c *GithubListBranchesHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	ctx, span := telemetry.NewSpan(r.Context(), "serve-list-github-branches")
+	defer span.End()
+
 	owner, name, ok := commonutils.GetOwnerAndNameParams(c, w, r)
 	owner, name, ok := commonutils.GetOwnerAndNameParams(c, w, r)
 
 
 	if !ok {
 	if !ok {
+		_ = telemetry.Error(ctx, span, nil, "could not get owner and name from request")
 		return
 		return
 	}
 	}
 
 
+	telemetry.WithAttributes(span,
+		telemetry.AttributeKV{Key: "owner", Value: owner},
+		telemetry.AttributeKV{Key: "name", Value: name},
+	)
+
 	client, err := GetGithubAppClientFromRequest(c.Config(), r)
 	client, err := GetGithubAppClientFromRequest(c.Config(), r)
 	if err != nil {
 	if err != nil {
+		err = telemetry.Error(ctx, span, err, "could not get github app client")
 		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
 		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
 		return
 		return
 	}
 	}
@@ -49,6 +61,7 @@ func (c *GithubListBranchesHandler) ServeHTTP(w http.ResponseWriter, r *http.Req
 		},
 		},
 	})
 	})
 	if err != nil {
 	if err != nil {
+		err = telemetry.Error(ctx, span, err, "could not list branches")
 		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
 		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
 		return
 		return
 	}
 	}
@@ -104,6 +117,7 @@ func (c *GithubListBranchesHandler) ServeHTTP(w http.ResponseWriter, r *http.Req
 	wg.Wait()
 	wg.Wait()
 
 
 	if workerErr != nil {
 	if workerErr != nil {
+		err = telemetry.Error(ctx, span, workerErr, "worker error listing github branches")
 		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
 		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
 		return
 		return
 	}
 	}

+ 87 - 0
api/server/handlers/gitinstallation/workflow_log_runid.go

@@ -0,0 +1,87 @@
+package gitinstallation
+
+import (
+	"fmt"
+	"net/http"
+	"strconv"
+	"strings"
+
+	"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/commonutils"
+	"github.com/porter-dev/porter/api/server/shared/config"
+)
+
+type GetSpecificWorkflowLogsHandler struct {
+	handlers.PorterHandlerReadWriter
+}
+
+func NewGetSpecificWorkflowLogsHandler(
+	config *config.Config,
+	decoderValidator shared.RequestDecoderValidator,
+	writer shared.ResultWriter,
+) *GetSpecificWorkflowLogsHandler {
+	return &GetSpecificWorkflowLogsHandler{
+		PorterHandlerReadWriter: handlers.NewDefaultPorterHandler(config, decoderValidator, writer),
+	}
+}
+
+func (c *GetSpecificWorkflowLogsHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	owner, name, ok := commonutils.GetOwnerAndNameParams(c, w, r)
+	if !ok {
+		return
+	}
+
+	releaseName := r.URL.Query().Get("release_name")
+	filename := r.URL.Query().Get("filename")
+	runNumberStr := r.URL.Query().Get("run_id")
+	fmt.Println(runNumberStr)
+
+	if filename == "" && releaseName == "" {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(fmt.Errorf("filename and release name are both empty")))
+		return
+	}
+
+	if filename == "" {
+		if c.Config().ServerConf.InstanceName != "" {
+			filename = fmt.Sprintf("porter_%s_%s.yml", strings.Replace(
+				strings.ToLower(releaseName), "-", "_", -1),
+				strings.ToLower(c.Config().ServerConf.InstanceName),
+			)
+		} else {
+			filename = fmt.Sprintf("porter_%s.yml", strings.Replace(
+				strings.ToLower(releaseName), "-", "_", -1),
+			)
+		}
+	}
+
+	client, err := GetGithubAppClientFromRequest(c.Config(), r)
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	// parse runNumber from string to int64
+	runNumber, err := strconv.ParseInt(runNumberStr, 10, 64)
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	specificWorkflowRun, _, err := client.Actions.GetWorkflowRunByID(r.Context(), owner, name, runNumber)
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	logsURL, _, err := client.Actions.GetWorkflowRunLogs(r.Context(), owner, name, specificWorkflowRun.GetID(), false)
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	fmt.Printf("Fetched specific workflow logs URL: %v\n", logsURL.String())
+
+	c.WriteResult(w, r, logsURL.String())
+}

+ 79 - 0
api/server/handlers/gitinstallation/workflow_logs.go

@@ -0,0 +1,79 @@
+package gitinstallation
+
+import (
+	"fmt"
+	"net/http"
+	"strings"
+
+	"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/commonutils"
+	"github.com/porter-dev/porter/api/server/shared/config"
+)
+
+type GetWorkflowLogsHandler struct {
+	handlers.PorterHandlerReadWriter
+}
+
+func NewGetWorkflowLogsHandler(
+	config *config.Config,
+	decoderValidator shared.RequestDecoderValidator,
+	writer shared.ResultWriter,
+) *GetWorkflowLogsHandler {
+	return &GetWorkflowLogsHandler{
+		PorterHandlerReadWriter: handlers.NewDefaultPorterHandler(config, decoderValidator, writer),
+	}
+}
+
+func (c *GetWorkflowLogsHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	owner, name, ok := commonutils.GetOwnerAndNameParams(c, w, r)
+
+	if !ok {
+		return
+	}
+
+	releaseName := r.URL.Query().Get("release_name")
+	filename := r.URL.Query().Get("filename")
+
+	if filename == "" && releaseName == "" {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(fmt.Errorf("filename and release name are both empty")))
+		return
+	}
+
+	if filename == "" {
+		if c.Config().ServerConf.InstanceName != "" {
+			filename = fmt.Sprintf("porter_%s_%s.yml", strings.Replace(
+				strings.ToLower(releaseName), "-", "_", -1),
+				strings.ToLower(c.Config().ServerConf.InstanceName),
+			)
+		} else {
+			filename = fmt.Sprintf("porter_%s.yml", strings.Replace(
+				strings.ToLower(releaseName), "-", "_", -1),
+			)
+		}
+	}
+
+	client, err := GetGithubAppClientFromRequest(c.Config(), r)
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	latestWorkflowRun, err := commonutils.GetLatestWorkflowRun(client, owner, name, filename, "")
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	logsURL, _, err := client.Actions.GetWorkflowRunLogs(r.Context(), owner, name, latestWorkflowRun.GetID(), false)
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	fmt.Printf("Fetched workflow logs URL: %v\n", logsURL.String())
+
+	c.WriteResult(w, r, logsURL.String())
+
+}

+ 1 - 1
api/server/handlers/stacks/porter_app_analytics.go → api/server/handlers/porter_app/analytics.go

@@ -1,4 +1,4 @@
-package stacks
+package porter_app
 
 
 import (
 import (
 	"net/http"
 	"net/http"

+ 484 - 0
api/server/handlers/porter_app/create.go

@@ -0,0 +1,484 @@
+package porter_app
+
+import (
+	"context"
+	"encoding/base64"
+	"fmt"
+	"net/http"
+	"strings"
+
+	"github.com/google/uuid"
+	"github.com/porter-dev/porter/internal/telemetry"
+
+	"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/helm"
+	"github.com/porter-dev/porter/internal/helm/loader"
+	"github.com/porter-dev/porter/internal/models"
+	"github.com/porter-dev/porter/internal/repository"
+	"github.com/stefanmcshane/helm/pkg/chart"
+)
+
+type CreatePorterAppHandler struct {
+	handlers.PorterHandlerReadWriter
+	authz.KubernetesAgentGetter
+}
+
+func NewCreatePorterAppHandler(
+	config *config.Config,
+	decoderValidator shared.RequestDecoderValidator,
+	writer shared.ResultWriter,
+) *CreatePorterAppHandler {
+	return &CreatePorterAppHandler{
+		PorterHandlerReadWriter: handlers.NewDefaultPorterHandler(config, decoderValidator, writer),
+		KubernetesAgentGetter:   authz.NewOutOfClusterAgentGetter(config),
+	}
+}
+
+func (c *CreatePorterAppHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	ctx := r.Context()
+	project, _ := ctx.Value(types.ProjectScope).(*models.Project)
+	cluster, _ := ctx.Value(types.ClusterScope).(*models.Cluster)
+
+	ctx, span := telemetry.NewSpan(r.Context(), "serve-create-porter-app")
+	defer span.End()
+
+	request := &types.CreatePorterAppRequest{}
+	if ok := c.DecodeAndValidate(w, r, request); !ok {
+		err := telemetry.Error(ctx, span, nil, "error decoding request")
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusBadRequest))
+		return
+	}
+
+	stackName, reqErr := requestutils.GetURLParamString(r, types.URLParamStackName)
+	if reqErr != nil {
+		err := telemetry.Error(ctx, span, reqErr, "error getting stack name from url")
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusBadRequest))
+		return
+	}
+	namespace := fmt.Sprintf("porter-stack-%s", stackName)
+	telemetry.WithAttributes(span, telemetry.AttributeKV{Key: "application-name", Value: stackName})
+
+	helmAgent, err := c.GetHelmAgent(ctx, r, cluster, namespace)
+	if err != nil {
+		err = telemetry.Error(ctx, span, err, "error getting helm agent")
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
+		return
+	}
+
+	k8sAgent, err := c.GetAgent(r, cluster, namespace)
+	if err != nil {
+		err = telemetry.Error(ctx, span, err, "error getting k8s agent")
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
+		return
+	}
+
+	helmRelease, err := helmAgent.GetRelease(ctx, stackName, 0, false)
+	shouldCreate := err != nil
+
+	porterYamlBase64 := request.PorterYAMLBase64
+	porterYaml, err := base64.StdEncoding.DecodeString(porterYamlBase64)
+	if err != nil {
+		err = telemetry.Error(ctx, span, err, "error decoding porter yaml")
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusBadRequest))
+		return
+	}
+	imageInfo := request.ImageInfo
+	registries, err := c.Repo().Registry().ListRegistriesByProjectID(cluster.ProjectID)
+	if err != nil {
+		err = telemetry.Error(ctx, span, err, "error listing registries")
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
+		return
+	}
+
+	var releaseValues map[string]interface{}
+	var releaseDependencies []*chart.Dependency
+	if shouldCreate || request.OverrideRelease {
+		releaseValues = nil
+		releaseDependencies = nil
+
+		// this is required because when the front-end sends an update request with overrideRelease=true, it is unable to
+		// get the image info from the release. unless it is explicitly provided in the request, we avoid overwriting it
+		// by attempting to get the image info from the release
+		if helmRelease != nil && (imageInfo.Repository == "" || imageInfo.Tag == "") {
+			imageInfo = attemptToGetImageInfoFromRelease(helmRelease.Config)
+		}
+	} else {
+		releaseValues = helmRelease.Config
+		releaseDependencies = helmRelease.Chart.Metadata.Dependencies
+	}
+	telemetry.WithAttributes(span, telemetry.AttributeKV{Key: "image-repo", Value: imageInfo.Repository}, telemetry.AttributeKV{Key: "image-tag", Value: imageInfo.Tag})
+
+	if request.Builder == "" {
+		// attempt to get builder from db
+		app, err := c.Repo().PorterApp().ReadPorterAppByName(cluster.ID, stackName)
+		if err == nil {
+			request.Builder = app.Builder
+		}
+	}
+	injectLauncher := strings.Contains(request.Builder, "heroku") ||
+		strings.Contains(request.Builder, "paketo")
+	telemetry.WithAttributes(span, telemetry.AttributeKV{Key: "builder", Value: request.Builder})
+
+	chart, values, releaseJobValues, err := parse(
+		porterYaml,
+		imageInfo,
+		c.Config(),
+		cluster.ProjectID,
+		releaseValues,
+		releaseDependencies,
+		SubdomainCreateOpts{
+			k8sAgent:       k8sAgent,
+			dnsRepo:        c.Repo().DNSRecord(),
+			powerDnsClient: c.Config().PowerDNSClient,
+			appRootDomain:  c.Config().ServerConf.AppRootDomain,
+			stackName:      stackName,
+		},
+		injectLauncher,
+	)
+	if err != nil {
+		err = telemetry.Error(ctx, span, err, "error parsing porter yaml into chart and values")
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
+		return
+	}
+
+	if shouldCreate {
+		telemetry.WithAttributes(span, telemetry.AttributeKV{Key: "installing-application", Value: true})
+
+		// create the namespace if it does not exist already
+		_, err = k8sAgent.CreateNamespace(namespace, nil)
+		if err != nil {
+			err = telemetry.Error(ctx, span, err, "error creating namespace")
+			c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
+			return
+		}
+
+		// create the release job chart if it does not exist (only done by front-end currently, where we set overrideRelease=true)
+		if request.OverrideRelease && releaseJobValues != nil {
+			telemetry.WithAttributes(span, telemetry.AttributeKV{Key: "installing-pre-deploy-job", Value: true})
+			conf, err := createReleaseJobChart(
+				ctx,
+				stackName,
+				releaseJobValues,
+				c.Config().ServerConf.DefaultApplicationHelmRepoURL,
+				registries,
+				cluster,
+				c.Repo(),
+			)
+			if err != nil {
+				err = telemetry.Error(ctx, span, err, "error making config for pre-deploy job chart")
+				c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
+				return
+			}
+			_, err = helmAgent.InstallChart(ctx, conf, c.Config().DOConf, c.Config().ServerConf.DisablePullSecretsInjection)
+			if err != nil {
+				err = telemetry.Error(ctx, span, err, "error installing pre-deploy job chart")
+				telemetry.WithAttributes(span, telemetry.AttributeKV{Key: "install-pre-deploy-job-error", Value: err})
+				c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
+				_, uninstallChartErr := helmAgent.UninstallChart(ctx, fmt.Sprintf("%s-r", stackName))
+				if uninstallChartErr != nil {
+					uninstallChartErr = telemetry.Error(ctx, span, err, "error uninstalling pre-deploy job chart after failed install")
+					c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(uninstallChartErr, http.StatusInternalServerError))
+				}
+				return
+			}
+		}
+
+		conf := &helm.InstallChartConfig{
+			Chart:      chart,
+			Name:       stackName,
+			Namespace:  namespace,
+			Values:     values,
+			Cluster:    cluster,
+			Repo:       c.Repo(),
+			Registries: registries,
+		}
+
+		// create the app chart
+		_, err = helmAgent.InstallChart(ctx, conf, c.Config().DOConf, c.Config().ServerConf.DisablePullSecretsInjection)
+		if err != nil {
+			err = telemetry.Error(ctx, span, err, "error installing app chart")
+			c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusBadRequest))
+
+			_, err = helmAgent.UninstallChart(ctx, stackName)
+			if err != nil {
+				err = telemetry.Error(ctx, span, err, "error uninstalling app chart after failed install")
+				c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
+			}
+
+			return
+		}
+
+		existing, err := c.Repo().PorterApp().ReadPorterAppByName(cluster.ID, stackName)
+		if err != nil {
+			err = telemetry.Error(ctx, span, err, "error reading app from DB")
+			c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
+			return
+		} else if existing.Name != "" {
+			err = telemetry.Error(ctx, span, err, "app with name already exists in project")
+			c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusForbidden))
+			return
+		}
+
+		app := &models.PorterApp{
+			Name:      stackName,
+			ClusterID: cluster.ID,
+			ProjectID: project.ID,
+			RepoName:  request.RepoName,
+			GitRepoID: request.GitRepoID,
+			GitBranch: request.GitBranch,
+
+			BuildContext:   request.BuildContext,
+			Builder:        request.Builder,
+			Buildpacks:     request.Buildpacks,
+			Dockerfile:     request.Dockerfile,
+			ImageRepoURI:   request.ImageRepoURI,
+			PullRequestURL: request.PullRequestURL,
+			PorterYamlPath: request.PorterYamlPath,
+		}
+
+		// create the db entry
+		porterApp, err := c.Repo().PorterApp().UpdatePorterApp(app)
+		if err != nil {
+			err = telemetry.Error(ctx, span, err, "error writing app to DB")
+			c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
+			return
+		}
+
+		_, err = createPorterAppEvent(ctx, "SUCCESS", porterApp.ID, 1, imageInfo.Tag, c.Repo().PorterAppEvent())
+		if err != nil {
+			err = telemetry.Error(ctx, span, err, "error creating porter app event")
+			c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
+			return
+		}
+
+		c.WriteResult(w, r, porterApp.ToPorterAppType())
+	} else {
+		telemetry.WithAttributes(span, telemetry.AttributeKV{Key: "upgrading-application", Value: true})
+
+		// create/update the release job chart
+		if request.OverrideRelease {
+			if releaseJobValues == nil {
+				releaseJobName := fmt.Sprintf("%s-r", stackName)
+				_, err := helmAgent.GetRelease(ctx, releaseJobName, 0, false)
+				if err == nil {
+					// handle exception where the user has chosen to delete the release job
+					telemetry.WithAttributes(span, telemetry.AttributeKV{Key: "deleting-pre-deploy-job", Value: true})
+					_, err = helmAgent.UninstallChart(ctx, releaseJobName)
+					if err != nil {
+						err = telemetry.Error(ctx, span, err, "error uninstalling pre-deploy job chart")
+						c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
+						return
+					}
+				}
+			} else {
+				releaseJobName := fmt.Sprintf("%s-r", stackName)
+				helmRelease, err := helmAgent.GetRelease(ctx, releaseJobName, 0, false)
+				if err != nil {
+					telemetry.WithAttributes(span, telemetry.AttributeKV{Key: "creating-pre-deploy-job", Value: true})
+					conf, err := createReleaseJobChart(
+						ctx,
+						stackName,
+						releaseJobValues,
+						c.Config().ServerConf.DefaultApplicationHelmRepoURL,
+						registries,
+						cluster,
+						c.Repo(),
+					)
+					if err != nil {
+						err = telemetry.Error(ctx, span, err, "error making config for pre-deploy job chart")
+						c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
+						return
+					}
+
+					_, err = helmAgent.InstallChart(ctx, conf, c.Config().DOConf, c.Config().ServerConf.DisablePullSecretsInjection)
+					if err != nil {
+						err = telemetry.Error(ctx, span, err, "error installing pre-deploy job chart")
+						telemetry.WithAttributes(span, telemetry.AttributeKV{Key: "install-pre-deploy-job-error", Value: err})
+						c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
+						_, uninstallChartErr := helmAgent.UninstallChart(ctx, fmt.Sprintf("%s-r", stackName))
+						if uninstallChartErr != nil {
+							uninstallChartErr = telemetry.Error(ctx, span, err, "error uninstalling pre-deploy job chart after failed install")
+							c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(uninstallChartErr, http.StatusInternalServerError))
+						}
+						return
+					}
+				} else {
+					telemetry.WithAttributes(span, telemetry.AttributeKV{Key: "updating-pre-deploy-job", Value: true})
+					chart, err := loader.LoadChartPublic(ctx, c.Config().Metadata.DefaultAppHelmRepoURL, "job", "")
+					if err != nil {
+						err = telemetry.Error(ctx, span, err, "error loading latest job chart")
+						c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
+						return
+					}
+
+					conf := &helm.UpgradeReleaseConfig{
+						Name:       helmRelease.Name,
+						Cluster:    cluster,
+						Repo:       c.Repo(),
+						Registries: registries,
+						Values:     releaseJobValues,
+						Chart:      chart,
+					}
+					_, err = helmAgent.UpgradeReleaseByValues(ctx, conf, c.Config().DOConf, c.Config().ServerConf.DisablePullSecretsInjection, false)
+					if err != nil {
+						err = telemetry.Error(ctx, span, err, "error upgrading pre-deploy job chart")
+						c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
+						return
+					}
+				}
+			}
+		}
+
+		// update the app chart
+		conf := &helm.InstallChartConfig{
+			Chart:      chart,
+			Name:       stackName,
+			Namespace:  namespace,
+			Values:     values,
+			Cluster:    cluster,
+			Repo:       c.Repo(),
+			Registries: registries,
+		}
+
+		// update the chart
+		_, err = helmAgent.UpgradeInstallChart(ctx, conf, c.Config().DOConf, c.Config().ServerConf.DisablePullSecretsInjection)
+		if err != nil {
+			err = telemetry.Error(ctx, span, err, "error upgrading application")
+			c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusBadRequest))
+			return
+		}
+
+		// update the DB entry
+		app, err := c.Repo().PorterApp().ReadPorterAppByName(cluster.ID, stackName)
+		if err != nil {
+			err = telemetry.Error(ctx, span, err, "error reading app from DB")
+			c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
+			return
+		}
+
+		if request.RepoName != "" {
+			app.RepoName = request.RepoName
+		}
+		if request.GitBranch != "" {
+			app.GitBranch = request.GitBranch
+		}
+		if request.BuildContext != "" {
+			app.BuildContext = request.BuildContext
+		}
+		if request.Builder != "" {
+			if request.Builder == "null" {
+				app.Builder = ""
+			} else {
+				app.Builder = request.Builder
+			}
+		}
+		if request.Buildpacks != "" {
+			if request.Buildpacks == "null" {
+				app.Buildpacks = ""
+			} else {
+				app.Buildpacks = request.Buildpacks
+			}
+		}
+		if request.Dockerfile != "" {
+			if request.Dockerfile == "null" {
+				app.Dockerfile = ""
+			} else {
+				app.Dockerfile = request.Dockerfile
+			}
+		}
+		if request.ImageRepoURI != "" {
+			app.ImageRepoURI = request.ImageRepoURI
+		}
+		if request.PullRequestURL != "" {
+			app.PullRequestURL = request.PullRequestURL
+		}
+
+		telemetry.WithAttributes(
+			span,
+			telemetry.AttributeKV{Key: "updated-repo-name", Value: app.RepoName},
+			telemetry.AttributeKV{Key: "updated-git-branch", Value: app.GitBranch},
+			telemetry.AttributeKV{Key: "updated-build-context", Value: app.BuildContext},
+			telemetry.AttributeKV{Key: "updated-builder", Value: app.Builder},
+			telemetry.AttributeKV{Key: "updated-buildpacks", Value: app.Buildpacks},
+			telemetry.AttributeKV{Key: "updated-dockerfile", Value: app.Dockerfile},
+			telemetry.AttributeKV{Key: "updated-image-repo-uri", Value: app.ImageRepoURI},
+			telemetry.AttributeKV{Key: "updated-pull-request-url", Value: app.PullRequestURL},
+		)
+
+		updatedPorterApp, err := c.Repo().PorterApp().UpdatePorterApp(app)
+		if err != nil {
+			err = telemetry.Error(ctx, span, err, "error writing updated app to DB")
+			c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
+			return
+		}
+
+		_, err = createPorterAppEvent(ctx, "SUCCESS", updatedPorterApp.ID, helmRelease.Version+1, imageInfo.Tag, c.Repo().PorterAppEvent())
+		if err != nil {
+			err = telemetry.Error(ctx, span, err, "error creating porter app event")
+			c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
+			return
+		}
+
+		c.WriteResult(w, r, updatedPorterApp.ToPorterAppType())
+	}
+}
+
+// createPorterAppEvent creates an event for use in the activity feed
+func createPorterAppEvent(ctx context.Context, status string, appID uint, revision int, tag string, repo repository.PorterAppEventRepository) (*models.PorterAppEvent, error) {
+	event := models.PorterAppEvent{
+		ID:                 uuid.New(),
+		Status:             status,
+		Type:               "DEPLOY",
+		TypeExternalSource: "KUBERNETES",
+		PorterAppID:        appID,
+		Metadata: map[string]any{
+			"revision":  revision,
+			"image_tag": tag,
+		},
+	}
+
+	err := repo.CreateEvent(ctx, &event)
+	if err != nil {
+		return nil, err
+	}
+
+	if event.ID == uuid.Nil {
+		return nil, err
+	}
+
+	return &event, nil
+}
+
+func createReleaseJobChart(
+	ctx context.Context,
+	stackName string,
+	values map[string]interface{},
+	repoUrl string,
+	registries []*models.Registry,
+	cluster *models.Cluster,
+	repo repository.Repository,
+) (*helm.InstallChartConfig, error) {
+	chart, err := loader.LoadChartPublic(ctx, repoUrl, "job", "")
+	if err != nil {
+		return nil, err
+	}
+
+	releaseName := fmt.Sprintf("%s-r", stackName)
+	namespace := fmt.Sprintf("porter-stack-%s", stackName)
+
+	return &helm.InstallChartConfig{
+		Chart:      chart,
+		Name:       releaseName,
+		Namespace:  namespace,
+		Values:     values,
+		Cluster:    cluster,
+		Repo:       repo,
+		Registries: registries,
+	}, nil
+}

+ 196 - 0
api/server/handlers/porter_app/create_and_update_events.go

@@ -0,0 +1,196 @@
+package porter_app
+
+import (
+	"context"
+	"net/http"
+
+	"github.com/google/uuid"
+	"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/models"
+	"github.com/porter-dev/porter/internal/telemetry"
+)
+
+type CreateUpdatePorterAppEventHandler struct {
+	handlers.PorterHandlerReadWriter
+	authz.KubernetesAgentGetter
+}
+
+func NewCreateUpdatePorterAppEventHandler(
+	config *config.Config,
+	decoderValidator shared.RequestDecoderValidator,
+	writer shared.ResultWriter,
+) *CreateUpdatePorterAppEventHandler {
+	return &CreateUpdatePorterAppEventHandler{
+		PorterHandlerReadWriter: handlers.NewDefaultPorterHandler(config, decoderValidator, writer),
+	}
+}
+
+func (p *CreateUpdatePorterAppEventHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	ctx, span := telemetry.NewSpan(r.Context(), "serve-post-porter-app-event")
+	defer span.End()
+
+	cluster, _ := ctx.Value(types.ClusterScope).(*models.Cluster)
+	telemetry.WithAttributes(span,
+		telemetry.AttributeKV{Key: "cluster-id", Value: int(cluster.ID)},
+		telemetry.AttributeKV{Key: "project-id", Value: int(cluster.ProjectID)},
+	)
+
+	request := &types.CreateOrUpdatePorterAppEventRequest{}
+	if ok := p.DecodeAndValidate(w, r, request); !ok {
+		e := telemetry.Error(ctx, span, nil, "error decoding request")
+		p.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(e, http.StatusBadRequest))
+		return
+	}
+
+	stackName, reqErr := requestutils.GetURLParamString(r, types.URLParamStackName)
+	if reqErr != nil {
+		e := telemetry.Error(ctx, span, reqErr, "error parsing stack name from url")
+		p.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(e, http.StatusBadRequest))
+		return
+	}
+	telemetry.WithAttributes(span,
+		telemetry.AttributeKV{Key: "porter-app-name", Value: stackName},
+		telemetry.AttributeKV{Key: "porter-app-event-type-id", Value: request.Type},
+		telemetry.AttributeKV{Key: "porter-app-event-status", Value: request.Status},
+		telemetry.AttributeKV{Key: "porter-app-event-external-source", Value: request.TypeExternalSource},
+		telemetry.AttributeKV{Key: "porter-app-event-id", Value: request.ID},
+	)
+
+	if request.ID == "" {
+		event, err := p.createNewAppEvent(ctx, *cluster, stackName, request.Status, string(request.Type), request.TypeExternalSource, request.Metadata)
+		if err != nil {
+			e := telemetry.Error(ctx, span, err, "error creating new app event")
+			p.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(e, http.StatusBadRequest))
+			return
+		}
+		p.WriteResult(w, r, event)
+		return
+	}
+
+	event, err := p.updateExistingAppEvent(ctx, *cluster, stackName, *request)
+	if err != nil {
+		e := telemetry.Error(ctx, span, err, "error creating new app event")
+		p.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(e, http.StatusBadRequest))
+		return
+	}
+	p.WriteResult(w, r, event)
+}
+
+// createNewAppEvent will create a new app event for the given porter app name. If the app event is an agent event, then it will be created only if there is no existing event which has the agent ID. In the case that an existing event is found, that will be returned instead
+func (p *CreateUpdatePorterAppEventHandler) createNewAppEvent(ctx context.Context, cluster models.Cluster, porterAppName string, status string, eventType string, externalSource string, requestMetadata map[string]any) (types.PorterAppEvent, error) {
+	ctx, span := telemetry.NewSpan(ctx, "create-porter-app-event")
+	defer span.End()
+
+	app, err := p.Repo().PorterApp().ReadPorterAppByName(cluster.ID, porterAppName)
+	if err != nil {
+		return types.PorterAppEvent{}, telemetry.Error(ctx, span, err, "error retrieving porter app by name for cluster")
+	}
+	telemetry.WithAttributes(span,
+		telemetry.AttributeKV{Key: "porter-app-id", Value: app.ID},
+		telemetry.AttributeKV{Key: "porter-app-name", Value: porterAppName},
+		telemetry.AttributeKV{Key: "cluster-id", Value: int(cluster.ID)},
+		telemetry.AttributeKV{Key: "project-id", Value: int(cluster.ProjectID)},
+	)
+
+	if eventType == string(types.PorterAppEventType_AppEvent) {
+		// Agent has no way to know what the porter app event id is, so if we must dedup here
+		// TODO: create a filter to filter by only agent events. Not an issue now as app events are deduped per hour on the agent side
+		if agentEventID, ok := requestMetadata["agent_event_id"]; ok {
+			existingEvents, _, err := p.Repo().PorterAppEvent().ListEventsByPorterAppID(ctx, app.ID)
+			if err != nil {
+				return types.PorterAppEvent{}, telemetry.Error(ctx, span, err, "error listing porter app events for event type")
+			}
+
+			for _, existingEvent := range existingEvents {
+				if existingEvent.Type == eventType {
+					existingAgentEventID, ok := existingEvent.Metadata["agent_event_id"]
+					if !ok {
+						continue
+					}
+					if existingAgentEventID == 0 {
+						continue
+					}
+					if existingAgentEventID == agentEventID {
+						return existingEvent.ToPorterAppEvent(), nil
+					}
+				}
+			}
+		}
+	}
+
+	event := models.PorterAppEvent{
+		ID:                 uuid.New(),
+		Status:             status,
+		Type:               eventType,
+		TypeExternalSource: externalSource,
+		PorterAppID:        app.ID,
+		Metadata:           make(map[string]any),
+	}
+
+	for k, v := range requestMetadata {
+		event.Metadata[k] = v
+	}
+
+	err = p.Repo().PorterAppEvent().CreateEvent(ctx, &event)
+	if err != nil {
+		return types.PorterAppEvent{}, telemetry.Error(ctx, span, err, "error creating porter app event")
+	}
+
+	if event.ID == uuid.Nil {
+		return types.PorterAppEvent{}, telemetry.Error(ctx, span, nil, "porter app event not found")
+	}
+
+	return event.ToPorterAppEvent(), nil
+}
+
+func (p *CreateUpdatePorterAppEventHandler) updateExistingAppEvent(ctx context.Context, cluster models.Cluster, porterAppName string, submittedEvent types.CreateOrUpdatePorterAppEventRequest) (types.PorterAppEvent, error) {
+	ctx, span := telemetry.NewSpan(ctx, "update-porter-app-event")
+	defer span.End()
+
+	app, err := p.Repo().PorterApp().ReadPorterAppByName(cluster.ID, porterAppName)
+	if err != nil {
+		return types.PorterAppEvent{}, telemetry.Error(ctx, span, err, "error retrieving porter app by name for cluster")
+	}
+	telemetry.WithAttributes(span,
+		telemetry.AttributeKV{Key: "porter-app-id", Value: app.ID},
+		telemetry.AttributeKV{Key: "porter-app-name", Value: porterAppName},
+		telemetry.AttributeKV{Key: "cluster-id", Value: int(cluster.ID)},
+		telemetry.AttributeKV{Key: "project-id", Value: int(cluster.ProjectID)},
+	)
+
+	if submittedEvent.ID == "" {
+		return types.PorterAppEvent{}, telemetry.Error(ctx, span, nil, "porter app event id is required")
+	}
+	submittedEventID, err := uuid.Parse(submittedEvent.ID)
+	if err != nil {
+		return types.PorterAppEvent{}, telemetry.Error(ctx, span, err, "error parsing porter app event id as uuid")
+	}
+
+	existingAppEvent, err := p.Repo().PorterAppEvent().ReadEvent(ctx, submittedEventID)
+	if err != nil {
+		return types.PorterAppEvent{}, telemetry.Error(ctx, span, err, "error retrieving porter app event by id")
+	}
+
+	if submittedEvent.Status != "" {
+		existingAppEvent.Status = submittedEvent.Status
+	}
+
+	if submittedEvent.Metadata != nil {
+		for k, v := range submittedEvent.Metadata {
+			existingAppEvent.Metadata[k] = v
+		}
+	}
+
+	err = p.Repo().PorterAppEvent().UpdateEvent(ctx, &existingAppEvent)
+	if err != nil {
+		return types.PorterAppEvent{}, telemetry.Error(ctx, span, err, "error updating porter app event")
+	}
+
+	return existingAppEvent.ToPorterAppEvent(), nil
+}

+ 1 - 1
api/server/handlers/stacks/create_secret_and_open_pr.go → api/server/handlers/porter_app/create_secret_and_open_pr.go

@@ -1,4 +1,4 @@
-package stacks
+package porter_app
 
 
 import (
 import (
 	"errors"
 	"errors"

+ 1 - 1
api/server/handlers/stacks/delete_porter_app.go → api/server/handlers/porter_app/delete.go

@@ -1,4 +1,4 @@
-package stacks
+package porter_app
 
 
 import (
 import (
 	"net/http"
 	"net/http"

+ 1 - 1
api/server/handlers/stacks/get_porter_app.go → api/server/handlers/porter_app/get.go

@@ -1,4 +1,4 @@
-package stacks
+package porter_app
 
 
 import (
 import (
 	"net/http"
 	"net/http"

+ 153 - 0
api/server/handlers/porter_app/get_logs_within_time_range.go

@@ -0,0 +1,153 @@
+package porter_app
+
+import (
+	"fmt"
+	"net/http"
+	"strings"
+
+	"github.com/porter-dev/porter/api/server/authz"
+	"github.com/porter-dev/porter/api/server/handlers"
+	"github.com/porter-dev/porter/api/server/shared"
+	"github.com/porter-dev/porter/api/server/shared/apierrors"
+	"github.com/porter-dev/porter/api/server/shared/config"
+	"github.com/porter-dev/porter/api/types"
+	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/telemetry"
+	v1 "k8s.io/api/core/v1"
+)
+
+type GetLogsWithinTimeRangeHandler struct {
+	handlers.PorterHandlerReadWriter
+	authz.KubernetesAgentGetter
+}
+
+func NewGetLogsWithinTimeRangeHandler(
+	config *config.Config,
+	decoderValidator shared.RequestDecoderValidator,
+	writer shared.ResultWriter,
+) *GetLogsWithinTimeRangeHandler {
+	return &GetLogsWithinTimeRangeHandler{
+		PorterHandlerReadWriter: handlers.NewDefaultPorterHandler(config, decoderValidator, writer),
+		KubernetesAgentGetter:   authz.NewOutOfClusterAgentGetter(config),
+	}
+}
+
+func (c *GetLogsWithinTimeRangeHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	ctx, span := telemetry.NewSpan(r.Context(), "serve-get-logs-within-time-range")
+	defer span.End()
+	r = r.Clone(ctx)
+	cluster, _ := ctx.Value(types.ClusterScope).(*models.Cluster)
+
+	request := &types.GetChartLogsWithinTimeRangeRequest{}
+	if ok := c.DecodeAndValidate(w, r, request); !ok {
+		return
+	}
+
+	if request.StartRange.IsZero() || request.EndRange.IsZero() {
+		err := telemetry.Error(ctx, span, nil, "must provide start and end range")
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusBadRequest))
+		return
+	}
+
+	agent, err := c.GetAgent(r, cluster, "")
+	if err != nil {
+		_ = telemetry.Error(ctx, span, err, "unable to get agent")
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(fmt.Errorf("unable to get agent"), http.StatusInternalServerError))
+		return
+	}
+
+	// get agent service
+	agentSvc, err := porter_agent.GetAgentService(agent.Clientset)
+	if err != nil {
+		_ = telemetry.Error(ctx, span, err, "unable to get agent service")
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(fmt.Errorf("unable to get agent service"), http.StatusInternalServerError))
+		return
+	}
+
+	podValuesRequest := &types.GetPodValuesRequest{
+		StartRange:  &request.StartRange,
+		EndRange:    &request.EndRange,
+		Namespace:   request.Namespace,
+		MatchPrefix: request.ChartName,
+		Revision:    request.Revision,
+	}
+
+	var podSelector string
+	if request.ChartName == "" {
+		if request.PodSelector == "" {
+			err = telemetry.Error(ctx, span, nil, "must provide either chart name or pod selector")
+			c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusBadRequest))
+			return
+		}
+		podSelector = request.PodSelector
+	} else {
+		// get the pod values which will be used to get the correct pod selector
+		podVals, err := porter_agent.GetPodValues(agent.Clientset, agentSvc, podValuesRequest)
+		if err != nil {
+			_ = telemetry.Error(ctx, span, err, "unable to get pod values")
+			c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
+			return
+		}
+
+		if len(podVals) == 0 {
+			err = telemetry.Error(ctx, span, nil, "no pods found within timerange")
+			c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusNotFound))
+			return
+		}
+		if len(podVals) == 1 {
+			podSelector = podVals[0]
+		} else {
+			// TODO: why are pods being returned from get pod values whose timestamps don't overlap with the search range??
+			// hacky workaround for the above bug, only for jobs - get the pods, and then filter them by timestamp
+			var latestPod *v1.Pod
+			for _, v := range podVals {
+				name := strings.Split(v, "-hook")[0] + "-hook"
+				pods, err := agent.GetJobPods(request.Namespace, name)
+				if err != nil {
+					_ = telemetry.Error(ctx, span, err, "unable to get pods for job")
+					c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(fmt.Errorf("unable to get pods for job"), http.StatusInternalServerError))
+					return
+				}
+				for _, pod := range pods {
+					if pod.GetCreationTimestamp().Time.After(request.StartRange) && pod.GetCreationTimestamp().Time.Before(request.EndRange) {
+						if latestPod == nil || pod.GetCreationTimestamp().Time.After(latestPod.GetCreationTimestamp().Time) {
+							latestPod = &pod
+						}
+					}
+				}
+			}
+			if latestPod == nil {
+				err = telemetry.Error(ctx, span, nil, "no pods found within timerange")
+				c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusNotFound))
+				return
+			}
+			podSelector = latestPod.Name
+		}
+	}
+
+	telemetry.WithAttributes(
+		span,
+		telemetry.AttributeKV{Key: "pod-selector", Value: podSelector},
+		telemetry.AttributeKV{Key: "start-range", Value: request.StartRange.String()},
+		telemetry.AttributeKV{Key: "end-range", Value: request.EndRange.String()},
+	)
+
+	logRequest := &types.GetLogRequest{
+		Limit:       request.Limit,
+		StartRange:  &request.StartRange,
+		EndRange:    &request.EndRange,
+		Revision:    request.Revision,
+		PodSelector: podSelector,
+		Namespace:   request.Namespace,
+	}
+
+	logs, err := porter_agent.GetHistoricalLogs(agent.Clientset, agentSvc, logRequest)
+	if err != nil {
+		_ = telemetry.Error(ctx, span, err, "unable to get logs")
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(fmt.Errorf("unable to get logs for pod selector %s", podSelector), http.StatusInternalServerError))
+		return
+	}
+
+	c.WriteResult(w, r, logs)
+}

+ 1 - 1
api/server/handlers/stacks/list_porter_app.go → api/server/handlers/porter_app/list.go

@@ -1,4 +1,4 @@
-package stacks
+package porter_app
 
 
 import (
 import (
 	"net/http"
 	"net/http"

+ 233 - 0
api/server/handlers/porter_app/list_events.go

@@ -0,0 +1,233 @@
+package porter_app
+
+import (
+	"context"
+	"errors"
+	"fmt"
+	"net/http"
+	"reflect"
+	"strconv"
+
+	"github.com/bradleyfalzon/ghinstallation/v2"
+	"github.com/google/go-github/v41/github"
+	"github.com/google/uuid"
+	"github.com/gorilla/schema"
+	"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/models"
+	"github.com/porter-dev/porter/internal/repository/gorm/helpers"
+	"github.com/porter-dev/porter/internal/telemetry"
+	"gorm.io/gorm"
+)
+
+type PorterAppEventListHandler struct {
+	handlers.PorterHandlerWriter
+}
+
+func NewPorterAppEventListHandler(
+	config *config.Config,
+	writer shared.ResultWriter,
+) *PorterAppEventListHandler {
+	return &PorterAppEventListHandler{
+		PorterHandlerWriter: handlers.NewDefaultPorterHandler(config, nil, writer),
+	}
+}
+
+func (p *PorterAppEventListHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	ctx, span := telemetry.NewSpan(r.Context(), "serve-list-porter-app-events")
+	defer span.End()
+
+	cluster, _ := ctx.Value(types.ClusterScope).(*models.Cluster)
+	telemetry.WithAttributes(span,
+		telemetry.AttributeKV{Key: "cluster-id", Value: int(cluster.ID)},
+		telemetry.AttributeKV{Key: "project-id", Value: int(cluster.ProjectID)},
+	)
+
+	stackName, reqErr := requestutils.GetURLParamString(r, types.URLParamStackName)
+	if reqErr != nil {
+		e := telemetry.Error(ctx, span, nil, "error parsing stack name from url")
+		p.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(e, http.StatusBadRequest))
+		return
+	}
+
+	pr := types.PaginationRequest{}
+	d := schema.NewDecoder()
+	err := d.Decode(&pr, r.URL.Query())
+	if err != nil {
+		e := telemetry.Error(ctx, span, nil, "error decoding request")
+		p.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(e, http.StatusBadRequest))
+		return
+	}
+
+	app, err := p.Repo().PorterApp().ReadPorterAppByName(cluster.ID, stackName)
+	if err != nil {
+		p.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	porterAppEvents, paginatedResult, err := p.Repo().PorterAppEvent().ListEventsByPorterAppID(ctx, app.ID, helpers.WithPageSize(20), helpers.WithPage(int(pr.Page)))
+	if err != nil {
+		if !errors.Is(err, gorm.ErrRecordNotFound) {
+			e := telemetry.Error(ctx, span, nil, "error listing porter app events by porter app id")
+			p.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(e, http.StatusBadRequest))
+			return
+		}
+	}
+
+	for idx, appEvent := range porterAppEvents {
+		if appEvent.Status == "PROGRESSING" {
+			pae, err := p.updateExistingAppEvent(ctx, *cluster, stackName, *appEvent)
+			if err != nil {
+				e := telemetry.Error(ctx, span, nil, "unable to update existing porter app event")
+				p.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(e, http.StatusBadRequest))
+				return
+			}
+			porterAppEvents[idx] = &pae
+		}
+	}
+
+	res := struct {
+		Events []types.PorterAppEvent `json:"events"`
+		types.PaginationResponse
+	}{
+		PaginationResponse: types.PaginationResponse(paginatedResult),
+	}
+	res.Events = make([]types.PorterAppEvent, 0)
+
+	for _, porterApp := range porterAppEvents {
+		if porterApp == nil {
+			continue
+		}
+		pa := porterApp.ToPorterAppEvent()
+		res.Events = append(res.Events, pa)
+	}
+	p.WriteResult(w, r, res)
+}
+
+func (p *PorterAppEventListHandler) updateExistingAppEvent(ctx context.Context, cluster models.Cluster, stackName string, appEvent models.PorterAppEvent) (models.PorterAppEvent, error) {
+	ctx, span := telemetry.NewSpan(ctx, "update-porter-app-event")
+	defer span.End()
+
+	if appEvent.ID == uuid.Nil {
+		return models.PorterAppEvent{}, telemetry.Error(ctx, span, nil, "porter app event id is nil when updating")
+	}
+
+	event, err := p.Repo().PorterAppEvent().ReadEvent(ctx, appEvent.ID)
+	if err != nil {
+		return models.PorterAppEvent{}, telemetry.Error(ctx, span, err, "error retrieving porter app by name for cluster")
+	}
+
+	telemetry.WithAttributes(span,
+		telemetry.AttributeKV{Key: "porter-app-id", Value: event.PorterAppID},
+		telemetry.AttributeKV{Key: "porter-app-event-id", Value: event.ID.String()},
+		telemetry.AttributeKV{Key: "porter-app-event-status", Value: event.Status},
+		telemetry.AttributeKV{Key: "cluster-id", Value: int(cluster.ID)},
+		telemetry.AttributeKV{Key: "project-id", Value: int(cluster.ProjectID)},
+	)
+
+	if appEvent.Type == string(types.PorterAppEventType_Build) && appEvent.TypeExternalSource == "GITHUB" {
+		err = p.updateBuildEvent_Github(ctx, &event)
+		if err != nil {
+			return models.PorterAppEvent{}, telemetry.Error(ctx, span, err, "error updating porter app event for github build")
+		}
+	}
+
+	telemetry.WithAttributes(span, telemetry.AttributeKV{Key: "porter-app-event-updated-status", Value: event.Status})
+
+	err = p.Repo().PorterAppEvent().UpdateEvent(ctx, &event)
+	if err != nil {
+		return models.PorterAppEvent{}, telemetry.Error(ctx, span, err, "error creating porter app event")
+	}
+
+	if event.ID == uuid.Nil {
+		return models.PorterAppEvent{}, telemetry.Error(ctx, span, nil, "porter app event not found")
+	}
+
+	return event, nil
+}
+
+func (p *PorterAppEventListHandler) updateBuildEvent_Github(ctx context.Context, event *models.PorterAppEvent) error {
+	ctx, span := telemetry.NewSpan(ctx, "update-porter-app-build-event")
+	defer span.End()
+
+	repoOrg, ok := event.Metadata["org"].(string)
+	if !ok {
+		return telemetry.Error(ctx, span, nil, "error retrieving repo org from metadata")
+	}
+
+	repoName, ok := event.Metadata["repo"].(string)
+	if !ok {
+		return telemetry.Error(ctx, span, nil, "error retrieving repo name from metadata")
+	}
+
+	actionRunIDIface, ok := event.Metadata["action_run_id"]
+	if !ok {
+		return telemetry.Error(ctx, span, nil, "error retrieving action run id from metadata")
+	}
+	actionRunID, ok := actionRunIDIface.(float64)
+	if !ok {
+		telemetry.WithAttributes(span, telemetry.AttributeKV{Key: "action-run-id-type", Value: reflect.TypeOf(actionRunIDIface).String()})
+		return telemetry.Error(ctx, span, nil, "error converting action run id to int")
+	}
+
+	accountIDIface, ok := event.Metadata["github_account_id"]
+	if !ok {
+		return telemetry.Error(ctx, span, nil, "error retrieving github account id from metadata")
+	}
+	githubAccountID, ok := accountIDIface.(float64)
+	if !ok {
+		telemetry.WithAttributes(span, telemetry.AttributeKV{Key: "github-account-id-type", Value: reflect.TypeOf(accountIDIface).String()})
+		return telemetry.Error(ctx, span, nil, "error converting github account id to int")
+	}
+
+	// read the environment to get the environment id
+	env, err := p.Repo().GithubAppInstallation().ReadGithubAppInstallationByAccountID(int64(githubAccountID))
+	if err != nil {
+		return telemetry.Error(ctx, span, err, "error reading github environment by owner repo name")
+	}
+
+	ghClient, err := getGithubClientFromEnvironment(p.Config(), env.InstallationID)
+	if err != nil {
+		return telemetry.Error(ctx, span, err, "error getting github client using porter application")
+	}
+
+	actionRun, _, err := ghClient.Actions.GetWorkflowRunByID(ctx, repoOrg, repoName, int64(actionRunID))
+	if err != nil {
+		return telemetry.Error(ctx, span, err, "error getting github action run by id")
+	}
+
+	if *actionRun.Status == "completed" {
+		if *actionRun.Conclusion == "success" {
+			event.Status = "SUCCESS"
+		} else {
+			event.Status = "FAILED"
+		}
+	}
+
+	return nil
+}
+
+func getGithubClientFromEnvironment(config *config.Config, installationID int64) (*github.Client, error) {
+	// get the github app client
+	ghAppId, err := strconv.Atoi(config.ServerConf.GithubAppID)
+	if err != nil {
+		return nil, fmt.Errorf("malformed GITHUB_APP_ID in server configuration: %w", err)
+	}
+
+	// authenticate as github app installation
+	itr, err := ghinstallation.New(
+		http.DefaultTransport,
+		int64(ghAppId),
+		installationID,
+		config.ServerConf.GithubAppSecret,
+	)
+	if err != nil {
+		return nil, fmt.Errorf("error in creating github client from preview environment: %w", err)
+	}
+
+	return github.NewClient(&http.Client{Transport: itr}), nil
+}

+ 1 - 1
api/server/handlers/stacks/parse.go → api/server/handlers/porter_app/parse.go

@@ -1,4 +1,4 @@
-package stacks
+package porter_app
 
 
 import (
 import (
 	"fmt"
 	"fmt"

+ 94 - 0
api/server/handlers/porter_app/rollback.go

@@ -0,0 +1,94 @@
+package porter_app
+
+import (
+	"fmt"
+	"net/http"
+
+	"github.com/porter-dev/porter/api/server/authz"
+	"github.com/porter-dev/porter/api/server/handlers"
+	"github.com/porter-dev/porter/api/server/shared"
+	"github.com/porter-dev/porter/api/server/shared/apierrors"
+	"github.com/porter-dev/porter/api/server/shared/config"
+	"github.com/porter-dev/porter/api/server/shared/requestutils"
+	"github.com/porter-dev/porter/api/types"
+	"github.com/porter-dev/porter/internal/models"
+	"github.com/porter-dev/porter/internal/telemetry"
+)
+
+type RollbackPorterAppHandler struct {
+	handlers.PorterHandlerReadWriter
+	authz.KubernetesAgentGetter
+}
+
+func NewRollbackPorterAppHandler(
+	config *config.Config,
+	decoderValidator shared.RequestDecoderValidator,
+	writer shared.ResultWriter,
+) *RollbackPorterAppHandler {
+	return &RollbackPorterAppHandler{
+		PorterHandlerReadWriter: handlers.NewDefaultPorterHandler(config, decoderValidator, writer),
+		KubernetesAgentGetter:   authz.NewOutOfClusterAgentGetter(config),
+	}
+}
+
+func (c *RollbackPorterAppHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	ctx, span := telemetry.NewSpan(r.Context(), "serve-rollback-porter-app")
+	defer span.End()
+	cluster, _ := ctx.Value(types.ClusterScope).(*models.Cluster)
+
+	request := &types.RollbackPorterAppRequest{}
+	if ok := c.DecodeAndValidate(w, r, request); !ok {
+		err := telemetry.Error(ctx, span, nil, "error decoding request")
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusBadRequest))
+		return
+	}
+
+	stackName, reqErr := requestutils.GetURLParamString(r, types.URLParamStackName)
+	if reqErr != nil {
+		err := telemetry.Error(ctx, span, reqErr, "error getting stack name from url")
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusBadRequest))
+		return
+	}
+	telemetry.WithAttributes(span, telemetry.AttributeKV{Key: "stack-name", Value: stackName})
+	namespace := fmt.Sprintf("porter-stack-%s", stackName)
+
+	helmAgent, err := c.GetHelmAgent(ctx, r, cluster, namespace)
+	if err != nil {
+		err = telemetry.Error(ctx, span, err, "error getting helm agent")
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
+		return
+	}
+
+	helmRelease, err := helmAgent.GetRelease(ctx, stackName, 0, false)
+	if err != nil {
+		err = telemetry.Error(ctx, span, err, "error getting helm release")
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
+		return
+	}
+
+	imageInfo := attemptToGetImageInfoFromRelease(helmRelease.Config)
+	if imageInfo.Tag == "" {
+		imageInfo.Tag = "latest"
+	}
+
+	porterApp, err := c.Repo().PorterApp().ReadPorterAppByName(cluster.ID, stackName)
+	if err != nil {
+		err = telemetry.Error(ctx, span, err, "error getting porter app")
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
+		return
+	}
+
+	err = helmAgent.RollbackRelease(ctx, helmRelease.Name, request.Revision)
+	if err != nil {
+		err = telemetry.Error(ctx, span, err, "error rolling back release")
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
+		return
+	}
+
+	_, err = createPorterAppEvent(ctx, "SUCCESS", porterApp.ID, helmRelease.Version+1, imageInfo.Tag, c.Repo().PorterAppEvent())
+	if err != nil {
+		err = telemetry.Error(ctx, span, err, "error creating porter app event")
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
+		return
+	}
+}

+ 5 - 4
api/server/handlers/project/get_usage.go

@@ -40,10 +40,11 @@ func (p *ProjectGetUsageHandler) ServeHTTP(w http.ResponseWriter, r *http.Reques
 	res := &types.GetProjectUsageResponse{}
 	res := &types.GetProjectUsageResponse{}
 
 
 	currUsage, limit, usageCache, err := usage.GetUsage(&usage.GetUsageOpts{
 	currUsage, limit, usageCache, err := usage.GetUsage(&usage.GetUsageOpts{
-		Project:          proj,
-		DOConf:           p.Config().DOConf,
-		Repo:             p.Repo(),
-		WhitelistedUsers: p.Config().WhitelistedUsers,
+		Project:                          proj,
+		DOConf:                           p.Config().DOConf,
+		Repo:                             p.Repo(),
+		WhitelistedUsers:                 p.Config().WhitelistedUsers,
+		ClusterControlPlaneServiceClient: p.Config().ClusterControlPlaneClient,
 	})
 	})
 	if err != nil {
 	if err != nil {
 		p.HandleAPIError(w, r, apierrors.NewErrInternal(err))
 		p.HandleAPIError(w, r, apierrors.NewErrInternal(err))

+ 3 - 2
api/server/handlers/project_integration/list_gitlab.go

@@ -40,10 +40,11 @@ func (p *ListGitlabHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
 
 
 	for _, gitlabInt := range gitlabInts {
 	for _, gitlabInt := range gitlabInts {
 		username := p.getCurrentUsername(user.ID, project.ID, gitlabInt)
 		username := p.getCurrentUsername(user.ID, project.ID, gitlabInt)
+		glit := gitlabInt.ToGitlabIntegrationType()
 		res = append(res,
 		res = append(res,
 			&types.GitlabIntegrationWithUsername{
 			&types.GitlabIntegrationWithUsername{
-				*gitlabInt.ToGitlabIntegrationType(),
-				username,
+				Username:          username,
+				GitlabIntegration: *glit,
 			},
 			},
 		)
 		)
 	}
 	}

+ 0 - 3
api/server/handlers/release/create.go

@@ -51,9 +51,6 @@ func NewCreateReleaseHandler(
 }
 }
 
 
 func (c *CreateReleaseHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
 func (c *CreateReleaseHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
-	tracer, _ := telemetry.InitTracer(r.Context(), c.Config().TelemetryConfig)
-	defer tracer.Shutdown()
-
 	user, _ := r.Context().Value(types.UserScope).(*models.User)
 	user, _ := r.Context().Value(types.UserScope).(*models.User)
 	cluster, _ := r.Context().Value(types.ClusterScope).(*models.Cluster)
 	cluster, _ := r.Context().Value(types.ClusterScope).(*models.Cluster)
 	namespace := r.Context().Value(types.NamespaceScope).(string)
 	namespace := r.Context().Value(types.NamespaceScope).(string)

+ 45 - 35
api/server/handlers/release/upgrade_webhook.go

@@ -1,7 +1,6 @@
 package release
 package release
 
 
 import (
 import (
-	"context"
 	"fmt"
 	"fmt"
 	"net/http"
 	"net/http"
 	"net/url"
 	"net/url"
@@ -17,6 +16,7 @@ import (
 	"github.com/porter-dev/porter/internal/helm"
 	"github.com/porter-dev/porter/internal/helm"
 	"github.com/porter-dev/porter/internal/notifier"
 	"github.com/porter-dev/porter/internal/notifier"
 	"github.com/porter-dev/porter/internal/notifier/slack"
 	"github.com/porter-dev/porter/internal/notifier/slack"
+	"github.com/porter-dev/porter/internal/telemetry"
 	"gorm.io/gorm"
 	"gorm.io/gorm"
 )
 )
 
 
@@ -37,57 +37,73 @@ func NewWebhookHandler(
 }
 }
 
 
 func (c *WebhookHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
 func (c *WebhookHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	ctx, span := telemetry.NewSpan(r.Context(), "serve-webhook-deploy-with-token-handler")
+	defer span.End()
+
 	token, _ := requestutils.GetURLParamString(r, types.URLParamToken)
 	token, _ := requestutils.GetURLParamString(r, types.URLParamToken)
 
 
 	// retrieve release by token
 	// retrieve release by token
-	release, err := c.Repo().Release().ReadReleaseByWebhookToken(token)
+	dbRelease, err := c.Repo().Release().ReadReleaseByWebhookToken(token)
 	if err != nil {
 	if err != nil {
 		if err == gorm.ErrRecordNotFound {
 		if err == gorm.ErrRecordNotFound {
+			err = telemetry.Error(ctx, span, err, "release not found with given webhook")
 			// throw forbidden error, since we don't want a way to verify if webhooks exist
 			// throw forbidden error, since we don't want a way to verify if webhooks exist
-			c.HandleAPIError(w, r, apierrors.NewErrForbidden(
-				fmt.Errorf("release not found with given webhook"),
-			))
-
+			c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusForbidden))
 			return
 			return
 		}
 		}
 
 
-		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		err = telemetry.Error(ctx, span, err, "error with reading release by webhook token")
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
+		return
+	}
+	if dbRelease == nil {
+		err = telemetry.Error(ctx, span, nil, "release is nil with given webhook")
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusForbidden))
 		return
 		return
 	}
 	}
+	release := *dbRelease
+
+	telemetry.WithAttributes(span,
+		telemetry.AttributeKV{Key: "release-id", Value: release.ID},
+		telemetry.AttributeKV{Key: "release-name", Value: release.Name},
+		telemetry.AttributeKV{Key: "release-namespace", Value: release.Namespace},
+		telemetry.AttributeKV{Key: "cluster-id", Value: release.ClusterID},
+		telemetry.AttributeKV{Key: "project-id", Value: release.ProjectID},
+	)
 
 
 	cluster, err := c.Repo().Cluster().ReadCluster(release.ProjectID, release.ClusterID)
 	cluster, err := c.Repo().Cluster().ReadCluster(release.ProjectID, release.ClusterID)
 	if err != nil {
 	if err != nil {
 		if err == gorm.ErrRecordNotFound {
 		if err == gorm.ErrRecordNotFound {
+			err = telemetry.Error(ctx, span, err, "cluster not found for upgrade webhook")
 			// throw forbidden error, since we don't want a way to verify if the cluster and project
 			// throw forbidden error, since we don't want a way to verify if the cluster and project
 			// still exist for a cluster that's been deleted
 			// still exist for a cluster that's been deleted
-			c.HandleAPIError(w, r, apierrors.NewErrForbidden(
-				fmt.Errorf("cluster %d in project %d not found for upgrade webhook", release.ClusterID, release.ProjectID),
-			))
-
+			c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusForbidden))
 			return
 			return
 		}
 		}
 
 
-		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		err = telemetry.Error(ctx, span, err, "error with reading cluster for upgrade webhook")
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
 		return
 		return
 	}
 	}
 
 
 	// in this case, we retrieve the agent by passing in the namespace field directly, since
 	// in this case, we retrieve the agent by passing in the namespace field directly, since
 	// it cannot be detected from the URL
 	// it cannot be detected from the URL
-	helmAgent, err := c.GetHelmAgent(r.Context(), r, cluster, release.Namespace)
+	helmAgent, err := c.GetHelmAgent(ctx, r, cluster, release.Namespace)
 	if err != nil {
 	if err != nil {
-		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		err = telemetry.Error(ctx, span, err, "unable to get helm agent for upgrade webhook")
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
 		return
 		return
 	}
 	}
 
 
 	request := &types.WebhookRequest{}
 	request := &types.WebhookRequest{}
-
 	if ok := c.DecodeAndValidate(w, r, request); !ok {
 	if ok := c.DecodeAndValidate(w, r, request); !ok {
 		return
 		return
 	}
 	}
 
 
-	rel, err := helmAgent.GetRelease(context.Background(), release.Name, 0, true)
+	rel, err := helmAgent.GetRelease(ctx, release.Name, 0, true)
 	if err != nil {
 	if err != nil {
-		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		err = telemetry.Error(ctx, span, err, "uanble to get release for upgrade webhook")
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
 		return
 		return
 	}
 	}
 
 
@@ -115,17 +131,15 @@ func (c *WebhookHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
 	rel.Config["image"] = image
 	rel.Config["image"] = image
 
 
 	if rel.Config["auto_deploy"] == false {
 	if rel.Config["auto_deploy"] == false {
-		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(
-			fmt.Errorf("Deploy webhook is disabled for this deployment."),
-			http.StatusBadRequest,
-		))
-
+		err = telemetry.Error(ctx, span, err, "deploy")
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusBadRequest))
 		return
 		return
 	}
 	}
 
 
 	registries, err := c.Repo().Registry().ListRegistriesByProjectID(release.ProjectID)
 	registries, err := c.Repo().Registry().ListRegistriesByProjectID(release.ProjectID)
 	if err != nil {
 	if err != nil {
-		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		err = telemetry.Error(ctx, span, err, "unable to list registries for upgrade webhook")
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
 		return
 		return
 	}
 	}
 
 
@@ -141,10 +155,11 @@ func (c *WebhookHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
 
 
 	var notifConf *types.NotificationConfig
 	var notifConf *types.NotificationConfig
 	notifConf = nil
 	notifConf = nil
-	if release != nil && release.NotificationConfig != 0 {
+	if release.NotificationConfig != 0 {
 		conf, err := c.Repo().NotificationConfig().ReadNotificationConfig(release.NotificationConfig)
 		conf, err := c.Repo().NotificationConfig().ReadNotificationConfig(release.NotificationConfig)
 		if err != nil {
 		if err != nil {
-			c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+			err = telemetry.Error(ctx, span, err, "unable to read notification config for upgrade webhook")
+			c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
 			return
 			return
 		}
 		}
 
 
@@ -169,8 +184,7 @@ func (c *WebhookHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
 		),
 		),
 	}
 	}
 
 
-	rel, err = helmAgent.UpgradeReleaseByValues(context.Background(), conf, c.Config().DOConf, c.Config().ServerConf.DisablePullSecretsInjection, false)
-
+	rel, err = helmAgent.UpgradeReleaseByValues(ctx, conf, c.Config().DOConf, c.Config().ServerConf.DisablePullSecretsInjection, false)
 	if err != nil {
 	if err != nil {
 		notifyOpts.Status = notifier.StatusHelmFailed
 		notifyOpts.Status = notifier.StatusHelmFailed
 		notifyOpts.Info = err.Error()
 		notifyOpts.Info = err.Error()
@@ -178,12 +192,8 @@ func (c *WebhookHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
 		if !cluster.NotificationsDisabled {
 		if !cluster.NotificationsDisabled {
 			deplNotifier.Notify(notifyOpts)
 			deplNotifier.Notify(notifyOpts)
 		}
 		}
-
-		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(
-			err,
-			http.StatusBadRequest,
-		))
-
+		err = telemetry.Error(ctx, span, err, "unable to upgrade release for upgrade webhook")
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusBadRequest))
 		return
 		return
 	}
 	}
 
 
@@ -211,9 +221,9 @@ func (c *WebhookHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
 	c.WriteResult(w, r, nil)
 	c.WriteResult(w, r, nil)
 
 
 	err = postUpgrade(c.Config(), cluster.ProjectID, cluster.ID, rel)
 	err = postUpgrade(c.Config(), cluster.ProjectID, cluster.ID, rel)
-
 	if err != nil {
 	if err != nil {
-		c.HandleAPIErrorNoWrite(w, r, apierrors.NewErrInternal(err))
+		err = telemetry.Error(ctx, span, err, "error while running post upgrade hooks")
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
 		return
 		return
 	}
 	}
 }
 }

+ 0 - 377
api/server/handlers/stacks/create_porter_app.go

@@ -1,377 +0,0 @@
-package stacks
-
-import (
-	"context"
-	"encoding/base64"
-	"fmt"
-	"net/http"
-	"strings"
-
-	"github.com/porter-dev/porter/internal/telemetry"
-
-	"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/helm"
-	"github.com/porter-dev/porter/internal/helm/loader"
-	"github.com/porter-dev/porter/internal/models"
-	"github.com/porter-dev/porter/internal/repository"
-	"github.com/stefanmcshane/helm/pkg/chart"
-)
-
-type CreatePorterAppHandler struct {
-	handlers.PorterHandlerReadWriter
-	authz.KubernetesAgentGetter
-}
-
-func NewCreatePorterAppHandler(
-	config *config.Config,
-	decoderValidator shared.RequestDecoderValidator,
-	writer shared.ResultWriter,
-) *CreatePorterAppHandler {
-	return &CreatePorterAppHandler{
-		PorterHandlerReadWriter: handlers.NewDefaultPorterHandler(config, decoderValidator, writer),
-		KubernetesAgentGetter:   authz.NewOutOfClusterAgentGetter(config),
-	}
-}
-
-func (c *CreatePorterAppHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
-	tracer, _ := telemetry.InitTracer(r.Context(), c.Config().TelemetryConfig)
-	defer tracer.Shutdown()
-
-	ctx := r.Context()
-	project, _ := ctx.Value(types.ProjectScope).(*models.Project)
-	cluster, _ := ctx.Value(types.ClusterScope).(*models.Cluster)
-
-	ctx, span := telemetry.NewSpan(r.Context(), "serve-create-porter-app")
-	defer span.End()
-
-	request := &types.CreatePorterAppRequest{}
-	if ok := c.DecodeAndValidate(w, r, request); !ok {
-		c.HandleAPIError(w, r, apierrors.NewErrInternal(fmt.Errorf("error decoding request")))
-		return
-	}
-
-	stackName, reqErr := requestutils.GetURLParamString(r, types.URLParamStackName)
-	if reqErr != nil {
-		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(reqErr, http.StatusBadRequest))
-		return
-	}
-	namespace := fmt.Sprintf("porter-stack-%s", stackName)
-
-	helmAgent, err := c.GetHelmAgent(ctx, r, cluster, namespace)
-	if err != nil {
-		c.HandleAPIError(w, r, apierrors.NewErrInternal(fmt.Errorf("error getting helm agent: %w", err)))
-		return
-	}
-
-	k8sAgent, err := c.GetAgent(r, cluster, namespace)
-	if err != nil {
-		c.HandleAPIError(w, r, apierrors.NewErrInternal(fmt.Errorf("error getting k8s agent: %w", err)))
-		return
-	}
-
-	helmRelease, err := helmAgent.GetRelease(ctx, stackName, 0, false)
-	shouldCreate := err != nil
-
-	porterYamlBase64 := request.PorterYAMLBase64
-	porterYaml, err := base64.StdEncoding.DecodeString(porterYamlBase64)
-	if err != nil {
-		c.HandleAPIError(w, r, apierrors.NewErrInternal(fmt.Errorf("error decoding porter.yaml: %w", err)))
-		return
-	}
-	imageInfo := request.ImageInfo
-	registries, err := c.Repo().Registry().ListRegistriesByProjectID(cluster.ProjectID)
-	if err != nil {
-		c.HandleAPIError(w, r, apierrors.NewErrInternal(fmt.Errorf("error listing registries: %w", err)))
-		return
-	}
-
-	var releaseValues map[string]interface{}
-	var releaseDependencies []*chart.Dependency
-	if shouldCreate || request.OverrideRelease {
-		releaseValues = nil
-		releaseDependencies = nil
-
-		// this is required because when the front-end sends an update request with overrideRelease=true, it is unable to
-		// get the image info from the release. unless it is explicitly provided in the request, we avoid overwriting it
-		// by attempting to get the image info from the release
-		if helmRelease != nil && (imageInfo.Repository == "" || imageInfo.Tag == "") {
-			imageInfo = attemptToGetImageInfoFromRelease(helmRelease.Config)
-		}
-	} else {
-		releaseValues = helmRelease.Config
-		releaseDependencies = helmRelease.Chart.Metadata.Dependencies
-	}
-
-	if request.Builder == "" {
-		// attempt to get builder from db
-		app, err := c.Repo().PorterApp().ReadPorterAppByName(cluster.ID, stackName)
-		if err == nil {
-			request.Builder = app.Builder
-		}
-	}
-	injectLauncher := strings.Contains(request.Builder, "heroku") ||
-		strings.Contains(request.Builder, "paketo")
-
-	chart, values, releaseJobValues, err := parse(
-		porterYaml,
-		imageInfo,
-		c.Config(),
-		cluster.ProjectID,
-		releaseValues,
-		releaseDependencies,
-		SubdomainCreateOpts{
-			k8sAgent:       k8sAgent,
-			dnsRepo:        c.Repo().DNSRecord(),
-			powerDnsClient: c.Config().PowerDNSClient,
-			appRootDomain:  c.Config().ServerConf.AppRootDomain,
-			stackName:      stackName,
-		},
-		injectLauncher,
-	)
-	if err != nil {
-		c.HandleAPIError(w, r, apierrors.NewErrInternal(fmt.Errorf("error parsing porter.yaml into chart and values: %w", err)))
-		return
-	}
-
-	if shouldCreate {
-		// create the namespace if it does not exist already
-		_, err = k8sAgent.CreateNamespace(namespace, nil)
-		if err != nil {
-			c.HandleAPIError(w, r, apierrors.NewErrInternal(fmt.Errorf("error creating namespace: %w", err)))
-			return
-		}
-
-		// create the release job chart if it does not exist (only done by front-end currently, where we set overrideRelease=true)
-		if request.OverrideRelease && releaseJobValues != nil {
-			conf, err := createReleaseJobChart(
-				ctx,
-				stackName,
-				releaseJobValues,
-				c.Config().ServerConf.DefaultApplicationHelmRepoURL,
-				registries,
-				cluster,
-				c.Repo(),
-			)
-			if err != nil {
-				c.HandleAPIError(w, r, apierrors.NewErrInternal(fmt.Errorf("error making config for release job chart: %w", err)))
-				return
-			}
-			_, err = helmAgent.InstallChart(ctx, conf, c.Config().DOConf, c.Config().ServerConf.DisablePullSecretsInjection)
-			if err != nil {
-				c.HandleAPIError(w, r, apierrors.NewErrInternal(fmt.Errorf("error creating release job chart: %w", err)))
-				_, err = helmAgent.UninstallChart(ctx, fmt.Sprintf("%s-r", stackName))
-				if err != nil {
-					c.HandleAPIError(w, r, apierrors.NewErrInternal(fmt.Errorf("error uninstalling release job chart: %w", err)))
-				}
-				return
-			}
-		}
-
-		conf := &helm.InstallChartConfig{
-			Chart:      chart,
-			Name:       stackName,
-			Namespace:  namespace,
-			Values:     values,
-			Cluster:    cluster,
-			Repo:       c.Repo(),
-			Registries: registries,
-		}
-
-		// create the app chart
-		_, err = helmAgent.InstallChart(ctx, conf, c.Config().DOConf, c.Config().ServerConf.DisablePullSecretsInjection)
-		if err != nil {
-			c.HandleAPIError(w, r, apierrors.NewErrInternal(fmt.Errorf("error deploying app: %s", err.Error())))
-
-			_, err = helmAgent.UninstallChart(ctx, stackName)
-			if err != nil {
-				c.HandleAPIError(w, r, apierrors.NewErrInternal(fmt.Errorf("error uninstalling chart: %w", err)))
-			}
-
-			return
-		}
-
-		existing, err := c.Repo().PorterApp().ReadPorterAppByName(cluster.ID, stackName)
-		if err != nil {
-			c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
-			return
-		} else if existing.Name != "" {
-			c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(
-				fmt.Errorf("app with name %s already exists in your project", existing.Name), http.StatusForbidden))
-			return
-		}
-
-		app := &models.PorterApp{
-			Name:      stackName,
-			ClusterID: cluster.ID,
-			ProjectID: project.ID,
-			RepoName:  request.RepoName,
-			GitRepoID: request.GitRepoID,
-			GitBranch: request.GitBranch,
-
-			BuildContext:   request.BuildContext,
-			Builder:        request.Builder,
-			Buildpacks:     request.Buildpacks,
-			Dockerfile:     request.Dockerfile,
-			ImageRepoURI:   request.ImageRepoURI,
-			PullRequestURL: request.PullRequestURL,
-			PorterYamlPath: request.PorterYamlPath,
-		}
-
-		// create the db entry
-		porterApp, err := c.Repo().PorterApp().UpdatePorterApp(app)
-		if err != nil {
-			c.HandleAPIError(w, r, apierrors.NewErrInternal(fmt.Errorf("error writing app to DB: %s", err.Error())))
-			return
-		}
-
-		c.WriteResult(w, r, porterApp.ToPorterAppType())
-	} else {
-		// create/update the release job chart
-		if request.OverrideRelease && releaseJobValues != nil {
-			releaseJobName := fmt.Sprintf("%s-r", stackName)
-			helmRelease, err := helmAgent.GetRelease(ctx, releaseJobName, 0, false)
-			if err != nil {
-				// here the user has created a release job for an already created app, so we need to create and install  the release job chart
-				conf, err := createReleaseJobChart(
-					ctx,
-					stackName,
-					releaseJobValues,
-					c.Config().ServerConf.DefaultApplicationHelmRepoURL,
-					registries,
-					cluster,
-					c.Repo(),
-				)
-				if err != nil {
-					c.HandleAPIError(w, r, apierrors.NewErrInternal(fmt.Errorf("error making config for release job chart: %w", err)))
-					return
-				}
-				_, err = helmAgent.InstallChart(ctx, conf, c.Config().DOConf, c.Config().ServerConf.DisablePullSecretsInjection)
-				if err != nil {
-					c.HandleAPIError(w, r, apierrors.NewErrInternal(fmt.Errorf("error creating release job chart: %w", err)))
-					_, err = helmAgent.UninstallChart(ctx, fmt.Sprintf("%s-r", stackName))
-					if err != nil {
-						c.HandleAPIError(w, r, apierrors.NewErrInternal(fmt.Errorf("error uninstalling release job chart: %w", err)))
-					}
-					return
-				}
-			} else {
-				conf := &helm.UpgradeReleaseConfig{
-					Name:       helmRelease.Name,
-					Cluster:    cluster,
-					Repo:       c.Repo(),
-					Registries: registries,
-					Values:     releaseJobValues,
-				}
-				_, err = helmAgent.UpgradeReleaseByValues(ctx, conf, c.Config().DOConf, c.Config().ServerConf.DisablePullSecretsInjection, false)
-				if err != nil {
-					c.HandleAPIError(w, r, apierrors.NewErrInternal(fmt.Errorf("error upgrading release job chart: %w", err)))
-					return
-				}
-			}
-		}
-
-		// update the app chart
-		conf := &helm.InstallChartConfig{
-			Chart:      chart,
-			Name:       stackName,
-			Namespace:  namespace,
-			Values:     values,
-			Cluster:    cluster,
-			Repo:       c.Repo(),
-			Registries: registries,
-		}
-
-		// update the chart
-		_, err = helmAgent.UpgradeInstallChart(ctx, conf, c.Config().DOConf, c.Config().ServerConf.DisablePullSecretsInjection)
-		if err != nil {
-			c.HandleAPIError(w, r, apierrors.NewErrInternal(fmt.Errorf("error deploying app: %s", err.Error())))
-			return
-		}
-
-		// update the DB entry
-		app, err := c.Repo().PorterApp().ReadPorterAppByName(cluster.ID, stackName)
-		if err != nil {
-			c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
-			return
-		}
-
-		if request.RepoName != "" {
-			app.RepoName = request.RepoName
-		}
-		if request.GitBranch != "" {
-			app.GitBranch = request.GitBranch
-		}
-		if request.BuildContext != "" {
-			app.BuildContext = request.BuildContext
-		}
-		if request.Builder != "" {
-			if request.Builder == "null" {
-				app.Builder = ""
-			} else {
-				app.Builder = request.Builder
-			}
-		}
-		if request.Buildpacks != "" {
-			if request.Buildpacks == "null" {
-				app.Buildpacks = ""
-			} else {
-				app.Buildpacks = request.Buildpacks
-			}
-		}
-		if request.Dockerfile != "" {
-			if request.Dockerfile == "null" {
-				app.Dockerfile = ""
-			} else {
-				app.Dockerfile = request.Dockerfile
-			}
-		}
-		if request.ImageRepoURI != "" {
-			app.ImageRepoURI = request.ImageRepoURI
-		}
-		if request.PullRequestURL != "" {
-			app.PullRequestURL = request.PullRequestURL
-		}
-
-		updatedPorterApp, err := c.Repo().PorterApp().UpdatePorterApp(app)
-		if err != nil {
-			c.HandleAPIError(w, r, apierrors.NewErrInternal(fmt.Errorf("error writing updated app to DB: %s", err.Error())))
-			return
-		}
-
-		c.WriteResult(w, r, updatedPorterApp.ToPorterAppType())
-	}
-}
-
-func createReleaseJobChart(
-	ctx context.Context,
-	stackName string,
-	values map[string]interface{},
-	repoUrl string,
-	registries []*models.Registry,
-	cluster *models.Cluster,
-	repo repository.Repository,
-) (*helm.InstallChartConfig, error) {
-	chart, err := loader.LoadChartPublic(ctx, repoUrl, "job", "")
-	if err != nil {
-		return nil, err
-	}
-
-	releaseName := fmt.Sprintf("%s-r", stackName)
-	namespace := fmt.Sprintf("porter-stack-%s", stackName)
-
-	return &helm.InstallChartConfig{
-		Chart:      chart,
-		Name:       releaseName,
-		Namespace:  namespace,
-		Values:     values,
-		Cluster:    cluster,
-		Repo:       repo,
-		Registries: registries,
-	}, nil
-}

+ 0 - 63
api/server/handlers/stacks/get_porter_app_events.go

@@ -1,63 +0,0 @@
-package stacks
-
-import (
-	"fmt"
-	"net/http"
-
-	"github.com/google/uuid"
-	"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"
-)
-
-type GetPorterAppEventHandler struct {
-	handlers.PorterHandlerReadWriter
-	authz.KubernetesAgentGetter
-}
-
-func NewGetPorterAppEventHandler(
-	config *config.Config,
-	writer shared.ResultWriter,
-) *GetPorterAppEventHandler {
-	return &GetPorterAppEventHandler{
-		PorterHandlerReadWriter: handlers.NewDefaultPorterHandler(config, nil, writer),
-	}
-}
-
-func (p *GetPorterAppEventHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
-	eventID, reqErr := requestutils.GetURLParamString(r, types.URLParamStackEventID)
-	if reqErr != nil {
-		p.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(reqErr, http.StatusBadRequest))
-		return
-	}
-
-	eventIDasUUID, err := uuid.Parse(eventID)
-	if err != nil {
-		e := fmt.Errorf("unable to parse porter app event id as uuid: %w", err)
-		p.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(e, http.StatusBadRequest))
-		return
-	}
-
-	if eventIDasUUID == uuid.Nil {
-		e := fmt.Errorf("invalid UUID passed for porter app event id")
-		p.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(e, http.StatusBadRequest))
-		return
-	}
-
-	event, err := p.Repo().PorterAppEvent().EventByID(eventIDasUUID)
-	if err != nil {
-		p.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusBadRequest))
-		return
-	}
-	if event.ID == uuid.Nil {
-		e := fmt.Errorf("porter app event not found")
-		p.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(e, http.StatusNotFound))
-		return
-	}
-
-	p.WriteResult(w, r, event.ToPorterAppEvent())
-}

+ 0 - 80
api/server/handlers/stacks/list_porter_app_events.go

@@ -1,80 +0,0 @@
-package stacks
-
-import (
-	"errors"
-	"net/http"
-
-	"github.com/gorilla/schema"
-	"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/models"
-	"github.com/porter-dev/porter/internal/repository/gorm/helpers"
-	"gorm.io/gorm"
-)
-
-type PorterAppEventListHandler struct {
-	handlers.PorterHandlerWriter
-}
-
-func NewPorterAppEventListHandler(
-	config *config.Config,
-	writer shared.ResultWriter,
-) *PorterAppEventListHandler {
-	return &PorterAppEventListHandler{
-		PorterHandlerWriter: handlers.NewDefaultPorterHandler(config, nil, writer),
-	}
-}
-
-func (p *PorterAppEventListHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
-	ctx := r.Context()
-	cluster, _ := ctx.Value(types.ClusterScope).(*models.Cluster)
-
-	stackName, reqErr := requestutils.GetURLParamString(r, types.URLParamStackName)
-	if reqErr != nil {
-		p.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(reqErr, http.StatusBadRequest))
-		return
-	}
-
-	pr := types.PaginationRequest{}
-	d := schema.NewDecoder()
-	err := d.Decode(&pr, r.URL.Query())
-	if err != nil {
-		p.HandleAPIError(w, r, apierrors.NewErrInternal(err))
-		return
-	}
-
-	app, err := p.Repo().PorterApp().ReadPorterAppByName(cluster.ID, stackName)
-	if err != nil {
-		p.HandleAPIError(w, r, apierrors.NewErrInternal(err))
-		return
-	}
-
-	porterApps, paginatedResult, err := p.Repo().PorterAppEvent().ListEventsByPorterAppID(app.ID, helpers.WithPageSize(20), helpers.WithPage(int(pr.Page)))
-	if err != nil {
-		if !errors.Is(err, gorm.ErrRecordNotFound) {
-			p.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(reqErr, http.StatusBadRequest))
-			return
-		}
-	}
-
-	res := struct {
-		Events []types.PorterAppEvent `json:"events"`
-		types.PaginationResponse
-	}{
-		PaginationResponse: types.PaginationResponse(paginatedResult),
-	}
-	res.Events = make([]types.PorterAppEvent, 0)
-
-	for _, porterApp := range porterApps {
-		if porterApp == nil {
-			continue
-		}
-		pa := porterApp.ToPorterAppEvent()
-		res.Events = append(res.Events, pa)
-	}
-	p.WriteResult(w, r, res)
-}

+ 1 - 1
api/server/router/base.go

@@ -3,7 +3,7 @@ package router
 import (
 import (
 	"fmt"
 	"fmt"
 
 
-	"github.com/go-chi/chi"
+	"github.com/go-chi/chi/v5"
 	"github.com/porter-dev/porter/api/server/handlers/credentials"
 	"github.com/porter-dev/porter/api/server/handlers/credentials"
 	"github.com/porter-dev/porter/api/server/handlers/gitinstallation"
 	"github.com/porter-dev/porter/api/server/handlers/gitinstallation"
 	"github.com/porter-dev/porter/api/server/handlers/healthcheck"
 	"github.com/porter-dev/porter/api/server/handlers/healthcheck"

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

@@ -3,7 +3,7 @@ package router
 import (
 import (
 	"fmt"
 	"fmt"
 
 
-	"github.com/go-chi/chi"
+	"github.com/go-chi/chi/v5"
 	"github.com/porter-dev/porter/api/server/handlers/cluster"
 	"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/database"
 	"github.com/porter-dev/porter/api/server/handlers/environment"
 	"github.com/porter-dev/porter/api/server/handlers/environment"

+ 1 - 1
api/server/router/cluster_integration.go

@@ -1,7 +1,7 @@
 package router
 package router
 
 
 import (
 import (
-	"github.com/go-chi/chi"
+	"github.com/go-chi/chi/v5"
 	awsClusterInt "github.com/porter-dev/porter/api/server/handlers/cluster_integration/aws"
 	awsClusterInt "github.com/porter-dev/porter/api/server/handlers/cluster_integration/aws"
 	"github.com/porter-dev/porter/api/server/shared"
 	"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/config"

+ 68 - 1
api/server/router/git_installation.go

@@ -3,7 +3,7 @@ package router
 import (
 import (
 	"fmt"
 	"fmt"
 
 
-	"github.com/go-chi/chi"
+	"github.com/go-chi/chi/v5"
 	"github.com/porter-dev/porter/api/server/handlers/environment"
 	"github.com/porter-dev/porter/api/server/handlers/environment"
 	"github.com/porter-dev/porter/api/server/handlers/gitinstallation"
 	"github.com/porter-dev/porter/api/server/handlers/gitinstallation"
 	"github.com/porter-dev/porter/api/server/shared"
 	"github.com/porter-dev/porter/api/server/shared"
@@ -735,5 +735,72 @@ func getGitInstallationRoutes(
 		Router:   r,
 		Router:   r,
 	})
 	})
 
 
+	getWorkflowLogsEndpoint := factory.NewAPIEndpoint(
+		&types.APIRequestMetadata{
+			Verb:   types.APIVerbGet,
+			Method: types.HTTPVerbGet,
+			Path: &types.Path{
+				Parent: basePath,
+				RelativePath: fmt.Sprintf(
+					"%s/{%s}/{%s}/clusters/{cluster_id}/get_logs_workflow",
+					relPath,
+					types.URLParamGitRepoOwner,
+					types.URLParamGitRepoName,
+				),
+			},
+			Scopes: []types.PermissionScope{
+				types.UserScope,
+				types.ProjectScope,
+				types.GitInstallationScope,
+				types.ClusterScope,
+			},
+		},
+	)
+
+	getWorkflowLogsHandler := gitinstallation.NewGetWorkflowLogsHandler(
+		config,
+		factory.GetDecoderValidator(),
+		factory.GetResultWriter(),
+	)
+
+	routes = append(routes, &router.Route{
+		Endpoint: getWorkflowLogsEndpoint,
+		Handler:  getWorkflowLogsHandler,
+		Router:   r,
+	})
+
+	getWorkflowLogByIDEndpoint := factory.NewAPIEndpoint(
+		&types.APIRequestMetadata{
+			Verb:   types.APIVerbGet,
+			Method: types.HTTPVerbGet,
+			Path: &types.Path{
+				Parent: basePath,
+				RelativePath: fmt.Sprintf(
+					"%s/{%s}/{%s}/clusters/{cluster_id}/workflow_run_id",
+					relPath,
+					types.URLParamGitRepoOwner,
+					types.URLParamGitRepoName,
+				),
+			},
+			Scopes: []types.PermissionScope{
+				types.UserScope,
+				types.ProjectScope,
+				types.GitInstallationScope,
+				types.ClusterScope,
+			},
+		},
+	)
+
+	getWorkflowLogByIDHandler := gitinstallation.NewGetSpecificWorkflowLogsHandler(
+		config,
+		factory.GetDecoderValidator(),
+		factory.GetResultWriter(),
+	)
+
+	routes = append(routes, &router.Route{
+		Endpoint: getWorkflowLogByIDEndpoint,
+		Handler:  getWorkflowLogByIDHandler,
+		Router:   r,
+	})
 	return routes, newPath
 	return routes, newPath
 }
 }

+ 1 - 1
api/server/router/helm_repo.go

@@ -1,7 +1,7 @@
 package router
 package router
 
 
 import (
 import (
-	"github.com/go-chi/chi"
+	"github.com/go-chi/chi/v5"
 	"github.com/porter-dev/porter/api/server/handlers/helmrepo"
 	"github.com/porter-dev/porter/api/server/handlers/helmrepo"
 	"github.com/porter-dev/porter/api/server/shared"
 	"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/config"

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

@@ -3,7 +3,7 @@ package router
 import (
 import (
 	"fmt"
 	"fmt"
 
 
-	"github.com/go-chi/chi"
+	"github.com/go-chi/chi/v5"
 	"github.com/porter-dev/porter/api/server/handlers/database"
 	"github.com/porter-dev/porter/api/server/handlers/database"
 	"github.com/porter-dev/porter/api/server/handlers/infra"
 	"github.com/porter-dev/porter/api/server/handlers/infra"
 	"github.com/porter-dev/porter/api/server/shared"
 	"github.com/porter-dev/porter/api/server/shared"

+ 1 - 1
api/server/router/invite.go

@@ -1,7 +1,7 @@
 package router
 package router
 
 
 import (
 import (
-	"github.com/go-chi/chi"
+	"github.com/go-chi/chi/v5"
 	"github.com/porter-dev/porter/api/server/handlers/invite"
 	"github.com/porter-dev/porter/api/server/handlers/invite"
 	"github.com/porter-dev/porter/api/server/shared"
 	"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/config"

+ 21 - 0
api/server/router/middleware/hydrate_trace.go

@@ -0,0 +1,21 @@
+package middleware
+
+import (
+	"net/http"
+
+	"github.com/porter-dev/porter/internal/telemetry"
+	"go.opentelemetry.io/otel/trace"
+)
+
+// HydrateTraces pulls related IDs from requests, and puts them into a span which already exists.
+// If no span already exists, these attibutes will not be populated. This should not be used as a replacement for creating your own spans.
+// This should be added as the last middleware in the chain, so that it can pull IDs from the request context.
+func HydrateTraces(next http.Handler) http.Handler {
+	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		ctx := r.Context()
+		span := trace.SpanFromContext(ctx)
+		telemetry.AddKnownContextVariablesToSpan(ctx, span)
+		r = r.Clone(ctx)
+		next.ServeHTTP(w, r)
+	})
+}

+ 30 - 21
api/server/router/middleware/usage.go

@@ -1,9 +1,10 @@
 package middleware
 package middleware
 
 
 import (
 import (
-	"fmt"
 	"net/http"
 	"net/http"
 
 
+	"github.com/porter-dev/porter/internal/telemetry"
+
 	"github.com/porter-dev/porter/api/server/shared/apierrors"
 	"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/config"
 	"github.com/porter-dev/porter/api/types"
 	"github.com/porter-dev/porter/api/types"
@@ -24,16 +25,24 @@ var UsageErrFmt = "usage limit reached for metric %s: limit %d, requested %d"
 
 
 func (b *UsageMiddleware) Middleware(next http.Handler) http.Handler {
 func (b *UsageMiddleware) Middleware(next http.Handler) http.Handler {
 	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
 	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
-		proj, _ := r.Context().Value(types.ProjectScope).(*models.Project)
+		ctx, span := telemetry.NewSpan(r.Context(), "middleware-usage")
+		defer span.End()
+
+		proj, _ := ctx.Value(types.ProjectScope).(*models.Project)
+
+		telemetry.WithAttributes(span, telemetry.AttributeKV{Key: "project-id", Value: proj.ID})
 
 
 		// get the project usage limits
 		// get the project usage limits
 		currentUsage, limit, _, err := usage.GetUsage(&usage.GetUsageOpts{
 		currentUsage, limit, _, err := usage.GetUsage(&usage.GetUsageOpts{
-			Project:          proj,
-			DOConf:           b.config.DOConf,
-			Repo:             b.config.Repo,
-			WhitelistedUsers: b.config.WhitelistedUsers,
+			Project:                          proj,
+			DOConf:                           b.config.DOConf,
+			Repo:                             b.config.Repo,
+			WhitelistedUsers:                 b.config.WhitelistedUsers,
+			ClusterControlPlaneServiceClient: b.config.ClusterControlPlaneClient,
 		})
 		})
 		if err != nil {
 		if err != nil {
+			err = telemetry.Error(ctx, span, err, "error getting usage")
+
 			apierrors.HandleAPIError(
 			apierrors.HandleAPIError(
 				b.config.Logger,
 				b.config.Logger,
 				b.config.Alerter,
 				b.config.Alerter,
@@ -45,25 +54,25 @@ func (b *UsageMiddleware) Middleware(next http.Handler) http.Handler {
 			return
 			return
 		}
 		}
 
 
+		telemetry.WithAttributes(span,
+			telemetry.AttributeKV{Key: "users-current-usage", Value: currentUsage.Users},
+			telemetry.AttributeKV{Key: "users-limit", Value: limit.Users},
+			telemetry.AttributeKV{Key: "cpu-current-usage", Value: currentUsage.ResourceCPU},
+			telemetry.AttributeKV{Key: "cpu-limit", Value: limit.ResourceCPU},
+			telemetry.AttributeKV{Key: "memory-current-usage", Value: currentUsage.ResourceMemory},
+			telemetry.AttributeKV{Key: "memory-limit", Value: limit.ResourceMemory},
+			telemetry.AttributeKV{Key: "clusters-current-usage", Value: currentUsage.Clusters},
+			telemetry.AttributeKV{Key: "clusters-limit", Value: limit.Clusters},
+		)
+
 		// check the usage limits
 		// check the usage limits
 		allowed := allowUsage(limit, currentUsage, b.metric)
 		allowed := allowUsage(limit, currentUsage, b.metric)
 
 
-		if allowed {
-			next.ServeHTTP(w, r)
-		} else {
-			limit, curr := getMetricUsage(limit, currentUsage, b.metric)
+		telemetry.WithAttributes(span, telemetry.AttributeKV{Key: "allowed", Value: allowed})
 
 
-			apierrors.HandleAPIError(
-				b.config.Logger,
-				b.config.Alerter,
-				w, r,
-				apierrors.NewErrPassThroughToClient(
-					fmt.Errorf(UsageErrFmt, b.metric, limit, curr),
-					http.StatusBadRequest,
-				),
-				true,
-			)
-		}
+		r = r.Clone(ctx)
+
+		next.ServeHTTP(w, r)
 	})
 	})
 }
 }
 
 

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

@@ -3,7 +3,7 @@ package router
 import (
 import (
 	"fmt"
 	"fmt"
 
 
-	"github.com/go-chi/chi"
+	"github.com/go-chi/chi/v5"
 
 
 	"github.com/porter-dev/porter/api/server/handlers/job"
 	"github.com/porter-dev/porter/api/server/handlers/job"
 	"github.com/porter-dev/porter/api/server/handlers/namespace"
 	"github.com/porter-dev/porter/api/server/handlers/namespace"

+ 1 - 1
api/server/router/oauth_callback.go

@@ -1,7 +1,7 @@
 package router
 package router
 
 
 import (
 import (
-	"github.com/go-chi/chi"
+	"github.com/go-chi/chi/v5"
 	"github.com/porter-dev/porter/api/server/handlers/oauth_callback"
 	"github.com/porter-dev/porter/api/server/handlers/oauth_callback"
 	"github.com/porter-dev/porter/api/server/shared"
 	"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/config"

+ 82 - 23
api/server/router/stack.go → api/server/router/porter_app.go

@@ -3,8 +3,8 @@ package router
 import (
 import (
 	"fmt"
 	"fmt"
 
 
-	"github.com/go-chi/chi"
-	"github.com/porter-dev/porter/api/server/handlers/stacks"
+	"github.com/go-chi/chi/v5"
+	"github.com/porter-dev/porter/api/server/handlers/porter_app"
 	"github.com/porter-dev/porter/api/server/shared"
 	"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/config"
 	"github.com/porter-dev/porter/api/server/shared/router"
 	"github.com/porter-dev/porter/api/server/shared/router"
@@ -55,7 +55,7 @@ func getStackRoutes(
 
 
 	var routes []*router.Route
 	var routes []*router.Route
 
 
-	// GET /api/projects/{project_id}/clusters/{cluster_id}/stacks/{name} -> stacks.NewPorterAppGetHandler
+	// GET /api/projects/{project_id}/clusters/{cluster_id}/stacks/{name} -> porter_app.NewPorterAppGetHandler
 	getPorterAppEndpoint := factory.NewAPIEndpoint(
 	getPorterAppEndpoint := factory.NewAPIEndpoint(
 		&types.APIRequestMetadata{
 		&types.APIRequestMetadata{
 			Verb:   types.APIVerbGet,
 			Verb:   types.APIVerbGet,
@@ -72,7 +72,7 @@ func getStackRoutes(
 		},
 		},
 	)
 	)
 
 
-	getPorterAppHandler := stacks.NewGetPorterAppHandler(
+	getPorterAppHandler := porter_app.NewGetPorterAppHandler(
 		config,
 		config,
 		factory.GetResultWriter(),
 		factory.GetResultWriter(),
 	)
 	)
@@ -83,7 +83,7 @@ func getStackRoutes(
 		Router:   r,
 		Router:   r,
 	})
 	})
 
 
-	// GET /api/projects/{project_id}/clusters/{cluster_id}/stacks/{name} -> stacks.NewPorterAppListHandler
+	// GET /api/projects/{project_id}/clusters/{cluster_id}/stacks/{name} -> porter_app.NewPorterAppListHandler
 	listPorterAppEndpoint := factory.NewAPIEndpoint(
 	listPorterAppEndpoint := factory.NewAPIEndpoint(
 		&types.APIRequestMetadata{
 		&types.APIRequestMetadata{
 			Verb:   types.APIVerbList,
 			Verb:   types.APIVerbList,
@@ -100,7 +100,7 @@ func getStackRoutes(
 		},
 		},
 	)
 	)
 
 
-	listPorterAppHandler := stacks.NewPorterAppListHandler(
+	listPorterAppHandler := porter_app.NewPorterAppListHandler(
 		config,
 		config,
 		factory.GetResultWriter(),
 		factory.GetResultWriter(),
 	)
 	)
@@ -128,7 +128,7 @@ func getStackRoutes(
 		},
 		},
 	)
 	)
 
 
-	deletePorterAppByNameHandler := stacks.NewDeletePorterAppByNameHandler(
+	deletePorterAppByNameHandler := porter_app.NewDeletePorterAppByNameHandler(
 		config,
 		config,
 		factory.GetDecoderValidator(),
 		factory.GetDecoderValidator(),
 		factory.GetResultWriter(),
 		factory.GetResultWriter(),
@@ -140,7 +140,7 @@ func getStackRoutes(
 		Router:   r,
 		Router:   r,
 	})
 	})
 
 
-	// POST /api/projects/{project_id}/clusters/{cluster_id}/stacks/{stack} -> stacks.NewCreatePorterAppHandler
+	// POST /api/projects/{project_id}/clusters/{cluster_id}/stacks/{stack} -> porter_app.NewCreatePorterAppHandler
 	createPorterAppEndpoint := factory.NewAPIEndpoint(
 	createPorterAppEndpoint := factory.NewAPIEndpoint(
 		&types.APIRequestMetadata{
 		&types.APIRequestMetadata{
 			Verb:   types.APIVerbCreate,
 			Verb:   types.APIVerbCreate,
@@ -157,7 +157,7 @@ func getStackRoutes(
 		},
 		},
 	)
 	)
 
 
-	createPorterAppHandler := stacks.NewCreatePorterAppHandler(
+	createPorterAppHandler := porter_app.NewCreatePorterAppHandler(
 		config,
 		config,
 		factory.GetDecoderValidator(),
 		factory.GetDecoderValidator(),
 		factory.GetResultWriter(),
 		factory.GetResultWriter(),
@@ -169,7 +169,36 @@ func getStackRoutes(
 		Router:   r,
 		Router:   r,
 	})
 	})
 
 
-	// POST /api/projects/{project_id}/clusters/{cluster_id}/stacks/{stack}/pr -> stacks.NewOpenStackPRHandler
+	// POST /api/projects/{project_id}/clusters/{cluster_id}/stacks/{stack}/rollback -> porter_app.NewRollbackPorterAppHandler
+	rollbackPorterAppEndpoint := factory.NewAPIEndpoint(
+		&types.APIRequestMetadata{
+			Verb:   types.APIVerbCreate,
+			Method: types.HTTPVerbPost,
+			Path: &types.Path{
+				Parent:       basePath,
+				RelativePath: fmt.Sprintf("%s/{%s}/rollback", relPath, types.URLParamStackName),
+			},
+			Scopes: []types.PermissionScope{
+				types.UserScope,
+				types.ProjectScope,
+				types.ClusterScope,
+			},
+		},
+	)
+
+	rollbackPorterAppHandler := porter_app.NewRollbackPorterAppHandler(
+		config,
+		factory.GetDecoderValidator(),
+		factory.GetResultWriter(),
+	)
+
+	routes = append(routes, &router.Route{
+		Endpoint: rollbackPorterAppEndpoint,
+		Handler:  rollbackPorterAppHandler,
+		Router:   r,
+	})
+
+	// POST /api/projects/{project_id}/clusters/{cluster_id}/stacks/{stack}/pr -> porter_app.NewOpenStackPRHandler
 	createSecretAndOpenGitHubPullRequestEndpoint := factory.NewAPIEndpoint(
 	createSecretAndOpenGitHubPullRequestEndpoint := factory.NewAPIEndpoint(
 		&types.APIRequestMetadata{
 		&types.APIRequestMetadata{
 			Verb:   types.APIVerbCreate,
 			Verb:   types.APIVerbCreate,
@@ -186,7 +215,7 @@ func getStackRoutes(
 		},
 		},
 	)
 	)
 
 
-	createSecretAndOpenGitHubPullRequestHandler := stacks.NewOpenStackPRHandler(
+	createSecretAndOpenGitHubPullRequestHandler := porter_app.NewOpenStackPRHandler(
 		config,
 		config,
 		factory.GetDecoderValidator(),
 		factory.GetDecoderValidator(),
 		factory.GetResultWriter(),
 		factory.GetResultWriter(),
@@ -198,7 +227,7 @@ func getStackRoutes(
 		Router:   r,
 		Router:   r,
 	})
 	})
 
 
-	// GET /api/projects/{project_id}/clusters/{cluster_id}/stacks/{name}/events -> stacks.NewPorterAppEventListHandler
+	// GET /api/projects/{project_id}/clusters/{cluster_id}/stacks/{name}/events -> porter_app.NewPorterAppEventListHandler
 	listPorterAppEventsEndpoint := factory.NewAPIEndpoint(
 	listPorterAppEventsEndpoint := factory.NewAPIEndpoint(
 		&types.APIRequestMetadata{
 		&types.APIRequestMetadata{
 			Verb:   types.APIVerbList,
 			Verb:   types.APIVerbList,
@@ -215,7 +244,7 @@ func getStackRoutes(
 		},
 		},
 	)
 	)
 
 
-	listPorterAppEventsHandler := stacks.NewPorterAppEventListHandler(
+	listPorterAppEventsHandler := porter_app.NewPorterAppEventListHandler(
 		config,
 		config,
 		factory.GetResultWriter(),
 		factory.GetResultWriter(),
 	)
 	)
@@ -226,14 +255,14 @@ func getStackRoutes(
 		Router:   r,
 		Router:   r,
 	})
 	})
 
 
-	// GET /api/projects/{project_id}/clusters/{cluster_id}/stacks/{name}/events/{stack_event_id} -> stacks.NewPorterAppEventGetHandler
-	getPorterAppEventEndpoint := factory.NewAPIEndpoint(
+	// POST /api/projects/{project_id}/clusters/{cluster_id}/stacks/{name}/events -> porter_app.NewCreatePorterAppEventEndpoint
+	createPorterAppEventEndpoint := factory.NewAPIEndpoint(
 		&types.APIRequestMetadata{
 		&types.APIRequestMetadata{
-			Verb:   types.APIVerbGet,
-			Method: types.HTTPVerbGet,
+			Verb:   types.APIVerbCreate,
+			Method: types.HTTPVerbPost,
 			Path: &types.Path{
 			Path: &types.Path{
 				Parent:       basePath,
 				Parent:       basePath,
-				RelativePath: fmt.Sprintf("%s/{%s}/events/{%s}", relPath, types.URLParamStackName, types.URLParamStackEventID),
+				RelativePath: fmt.Sprintf("%s/{%s}/events", relPath, types.URLParamStackName),
 			},
 			},
 			Scopes: []types.PermissionScope{
 			Scopes: []types.PermissionScope{
 				types.UserScope,
 				types.UserScope,
@@ -243,18 +272,19 @@ func getStackRoutes(
 		},
 		},
 	)
 	)
 
 
-	getPorterAppEventHandler := stacks.NewGetPorterAppEventHandler(
+	createPorterAppEventHandler := porter_app.NewCreateUpdatePorterAppEventHandler(
 		config,
 		config,
+		factory.GetDecoderValidator(),
 		factory.GetResultWriter(),
 		factory.GetResultWriter(),
 	)
 	)
 
 
 	routes = append(routes, &router.Route{
 	routes = append(routes, &router.Route{
-		Endpoint: getPorterAppEventEndpoint,
-		Handler:  getPorterAppEventHandler,
+		Endpoint: createPorterAppEventEndpoint,
+		Handler:  createPorterAppEventHandler,
 		Router:   r,
 		Router:   r,
 	})
 	})
 
 
-	// POST /api/projects/{project_id}/clusters/{cluster_id}/stacks/analytics -> stacks.NewPorterAppAnalyticsHandler
+	// POST /api/projects/{project_id}/clusters/{cluster_id}/stacks/analytics -> porter_app.NewPorterAppAnalyticsHandler
 	porterAppAnalyticsEndpoint := factory.NewAPIEndpoint(
 	porterAppAnalyticsEndpoint := factory.NewAPIEndpoint(
 		&types.APIRequestMetadata{
 		&types.APIRequestMetadata{
 			Verb:   types.APIVerbUpdate,
 			Verb:   types.APIVerbUpdate,
@@ -271,7 +301,7 @@ func getStackRoutes(
 		},
 		},
 	)
 	)
 
 
-	porterAppAnalyticsHandler := stacks.NewPorterAppAnalyticsHandler(
+	porterAppAnalyticsHandler := porter_app.NewPorterAppAnalyticsHandler(
 		config,
 		config,
 		factory.GetDecoderValidator(),
 		factory.GetDecoderValidator(),
 		factory.GetResultWriter(),
 		factory.GetResultWriter(),
@@ -283,5 +313,34 @@ func getStackRoutes(
 		Router:   r,
 		Router:   r,
 	})
 	})
 
 
+	// GET /api/projects/{project_id}/clusters/{cluster_id}/stacks/logs -> cluster.NewGetChartLogsWithinTimeRangeHandler
+	getChartLogsWithinTimeRangeEndpoint := factory.NewAPIEndpoint(
+		&types.APIRequestMetadata{
+			Verb:   types.APIVerbGet,
+			Method: types.HTTPVerbGet,
+			Path: &types.Path{
+				Parent:       basePath,
+				RelativePath: fmt.Sprintf("%s/logs", relPath),
+			},
+			Scopes: []types.PermissionScope{
+				types.UserScope,
+				types.ProjectScope,
+				types.ClusterScope,
+			},
+		},
+	)
+
+	getChartLogsWithinTimeRangeHandler := porter_app.NewGetLogsWithinTimeRangeHandler(
+		config,
+		factory.GetDecoderValidator(),
+		factory.GetResultWriter(),
+	)
+
+	routes = append(routes, &router.Route{
+		Endpoint: getChartLogsWithinTimeRangeEndpoint,
+		Handler:  getChartLogsWithinTimeRangeHandler,
+		Router:   r,
+	})
+
 	return routes, newPath
 	return routes, newPath
 }
 }

+ 1 - 1
api/server/router/project.go

@@ -3,7 +3,7 @@ package router
 import (
 import (
 	"fmt"
 	"fmt"
 
 
-	"github.com/go-chi/chi"
+	"github.com/go-chi/chi/v5"
 	apiContract "github.com/porter-dev/porter/api/server/handlers/api_contract"
 	apiContract "github.com/porter-dev/porter/api/server/handlers/api_contract"
 	"github.com/porter-dev/porter/api/server/handlers/api_token"
 	"github.com/porter-dev/porter/api/server/handlers/api_token"
 	"github.com/porter-dev/porter/api/server/handlers/billing"
 	"github.com/porter-dev/porter/api/server/handlers/billing"

+ 1 - 1
api/server/router/project_integration.go

@@ -3,7 +3,7 @@ package router
 import (
 import (
 	"fmt"
 	"fmt"
 
 
-	"github.com/go-chi/chi"
+	"github.com/go-chi/chi/v5"
 	project_integration "github.com/porter-dev/porter/api/server/handlers/project_integration"
 	project_integration "github.com/porter-dev/porter/api/server/handlers/project_integration"
 	"github.com/porter-dev/porter/api/server/shared"
 	"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/config"

+ 1 - 1
api/server/router/project_oauth.go

@@ -1,7 +1,7 @@
 package router
 package router
 
 
 import (
 import (
-	"github.com/go-chi/chi"
+	"github.com/go-chi/chi/v5"
 
 
 	"github.com/porter-dev/porter/api/server/handlers/project_oauth"
 	"github.com/porter-dev/porter/api/server/handlers/project_oauth"
 	"github.com/porter-dev/porter/api/server/shared"
 	"github.com/porter-dev/porter/api/server/shared"

+ 1 - 1
api/server/router/registry.go

@@ -3,7 +3,7 @@ package router
 import (
 import (
 	"fmt"
 	"fmt"
 
 
-	"github.com/go-chi/chi"
+	"github.com/go-chi/chi/v5"
 	"github.com/porter-dev/porter/api/server/handlers/registry"
 	"github.com/porter-dev/porter/api/server/handlers/registry"
 	"github.com/porter-dev/porter/api/server/shared"
 	"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/config"

+ 1 - 1
api/server/router/release.go

@@ -1,7 +1,7 @@
 package router
 package router
 
 
 import (
 import (
-	"github.com/go-chi/chi"
+	"github.com/go-chi/chi/v5"
 	"github.com/porter-dev/porter/api/server/handlers/release"
 	"github.com/porter-dev/porter/api/server/handlers/release"
 	"github.com/porter-dev/porter/api/server/shared"
 	"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/config"

+ 14 - 12
api/server/router/router.go

@@ -6,8 +6,8 @@ import (
 	"path"
 	"path"
 	"strings"
 	"strings"
 
 
-	"github.com/go-chi/chi"
 	chiMiddleware "github.com/go-chi/chi/middleware"
 	chiMiddleware "github.com/go-chi/chi/middleware"
+	"github.com/go-chi/chi/v5"
 	"github.com/porter-dev/porter/api/server/authn"
 	"github.com/porter-dev/porter/api/server/authn"
 	"github.com/porter-dev/porter/api/server/authz"
 	"github.com/porter-dev/porter/api/server/authz"
 	"github.com/porter-dev/porter/api/server/authz/policy"
 	"github.com/porter-dev/porter/api/server/authz/policy"
@@ -17,6 +17,7 @@ import (
 	"github.com/porter-dev/porter/api/server/shared/config"
 	"github.com/porter-dev/porter/api/server/shared/config"
 	"github.com/porter-dev/porter/api/server/shared/router"
 	"github.com/porter-dev/porter/api/server/shared/router"
 	"github.com/porter-dev/porter/api/types"
 	"github.com/porter-dev/porter/api/types"
+	"github.com/riandyrn/otelchi"
 )
 )
 
 
 func NewAPIRouter(config *config.Config) *chi.Mux {
 func NewAPIRouter(config *config.Config) *chi.Mux {
@@ -61,11 +62,11 @@ func NewAPIRouter(config *config.Config) *chi.Mux {
 	}
 	}
 
 
 	r.Route("/api", func(r chi.Router) {
 	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(middleware.ContentTypeJSON)
+		r.Use(
+			otelchi.Middleware("porter-server-middleware", otelchi.WithRequestMethodInSpanName(true), otelchi.WithChiRoutes(r)),
+			panicMW.Middleware,
+			middleware.ContentTypeJSON,
+		)
 
 
 		baseRoutes := baseRegisterer.GetRoutes(
 		baseRoutes := baseRegisterer.GetRoutes(
 			r,
 			r,
@@ -110,11 +111,11 @@ func NewAPIRouter(config *config.Config) *chi.Mux {
 	})
 	})
 
 
 	r.Route("/api/v1", func(r chi.Router) {
 	r.Route("/api/v1", 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(middleware.ContentTypeJSON)
+		r.Use(
+			otelchi.Middleware("porter-server-middleware", otelchi.WithRequestMethodInSpanName(true), otelchi.WithChiRoutes(r)),
+			panicMW.Middleware,
+			middleware.ContentTypeJSON,
+		)
 
 
 		var allRoutes []*router.Route
 		var allRoutes []*router.Route
 
 
@@ -294,10 +295,11 @@ func registerRoutes(config *config.Config, routes []*router.Route) {
 
 
 		if route.Endpoint.Metadata.CheckUsage && config.ServerConf.UsageTrackingEnabled {
 		if route.Endpoint.Metadata.CheckUsage && config.ServerConf.UsageTrackingEnabled {
 			usageMW := middleware.NewUsageMiddleware(config, route.Endpoint.Metadata.UsageMetric)
 			usageMW := middleware.NewUsageMiddleware(config, route.Endpoint.Metadata.UsageMetric)
-
 			atomicGroup.Use(usageMW.Middleware)
 			atomicGroup.Use(usageMW.Middleware)
 		}
 		}
 
 
+		atomicGroup.Use(middleware.HydrateTraces)
+
 		atomicGroup.Method(
 		atomicGroup.Method(
 			string(route.Endpoint.Metadata.Method),
 			string(route.Endpoint.Metadata.Method),
 			route.Endpoint.Metadata.Path.RelativePath,
 			route.Endpoint.Metadata.Path.RelativePath,

+ 1 - 1
api/server/router/slack_integration.go

@@ -1,7 +1,7 @@
 package router
 package router
 
 
 import (
 import (
-	"github.com/go-chi/chi"
+	"github.com/go-chi/chi/v5"
 	"github.com/porter-dev/porter/api/server/handlers/slack_integration"
 	"github.com/porter-dev/porter/api/server/handlers/slack_integration"
 	"github.com/porter-dev/porter/api/server/shared"
 	"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/config"

+ 1 - 1
api/server/router/status.go

@@ -1,7 +1,7 @@
 package router
 package router
 
 
 import (
 import (
-	"github.com/go-chi/chi"
+	"github.com/go-chi/chi/v5"
 	"github.com/porter-dev/porter/api/server/handlers/status"
 	"github.com/porter-dev/porter/api/server/handlers/status"
 	"github.com/porter-dev/porter/api/server/shared"
 	"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/config"

+ 1 - 1
api/server/router/user.go

@@ -3,7 +3,7 @@ package router
 import (
 import (
 	"fmt"
 	"fmt"
 
 
-	"github.com/go-chi/chi"
+	"github.com/go-chi/chi/v5"
 	"github.com/porter-dev/porter/api/server/handlers/gitinstallation"
 	"github.com/porter-dev/porter/api/server/handlers/gitinstallation"
 	"github.com/porter-dev/porter/api/server/handlers/project"
 	"github.com/porter-dev/porter/api/server/handlers/project"
 	"github.com/porter-dev/porter/api/server/handlers/template"
 	"github.com/porter-dev/porter/api/server/handlers/template"

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

@@ -3,7 +3,7 @@ package v1
 import (
 import (
 	"fmt"
 	"fmt"
 
 
-	"github.com/go-chi/chi"
+	"github.com/go-chi/chi/v5"
 	"github.com/porter-dev/porter/api/server/handlers/cluster"
 	"github.com/porter-dev/porter/api/server/handlers/cluster"
 	"github.com/porter-dev/porter/api/server/shared"
 	"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/config"

+ 1 - 1
api/server/router/v1/env_group.go

@@ -3,7 +3,7 @@ package v1
 import (
 import (
 	"fmt"
 	"fmt"
 
 
-	"github.com/go-chi/chi"
+	"github.com/go-chi/chi/v5"
 	v1EnvGroup "github.com/porter-dev/porter/api/server/handlers/v1/env_group"
 	v1EnvGroup "github.com/porter-dev/porter/api/server/handlers/v1/env_group"
 	"github.com/porter-dev/porter/api/server/shared"
 	"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/config"

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

@@ -1,7 +1,7 @@
 package v1
 package v1
 
 
 import (
 import (
-	"github.com/go-chi/chi"
+	"github.com/go-chi/chi/v5"
 	"github.com/porter-dev/porter/api/server/shared"
 	"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/config"
 	"github.com/porter-dev/porter/api/server/shared/router"
 	"github.com/porter-dev/porter/api/server/shared/router"

+ 1 - 1
api/server/router/v1/project.go

@@ -3,7 +3,7 @@ package v1
 import (
 import (
 	"fmt"
 	"fmt"
 
 
-	"github.com/go-chi/chi"
+	"github.com/go-chi/chi/v5"
 	"github.com/porter-dev/porter/api/server/shared"
 	"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/config"
 	"github.com/porter-dev/porter/api/server/shared/router"
 	"github.com/porter-dev/porter/api/server/shared/router"

+ 1 - 1
api/server/router/v1/registry.go

@@ -3,7 +3,7 @@ package v1
 import (
 import (
 	"fmt"
 	"fmt"
 
 
-	"github.com/go-chi/chi"
+	"github.com/go-chi/chi/v5"
 	"github.com/porter-dev/porter/api/server/handlers/registry"
 	"github.com/porter-dev/porter/api/server/handlers/registry"
 	v1Registry "github.com/porter-dev/porter/api/server/handlers/v1/registry"
 	v1Registry "github.com/porter-dev/porter/api/server/handlers/v1/registry"
 	"github.com/porter-dev/porter/api/server/shared"
 	"github.com/porter-dev/porter/api/server/shared"

+ 1 - 1
api/server/router/v1/release.go

@@ -1,7 +1,7 @@
 package v1
 package v1
 
 
 import (
 import (
-	"github.com/go-chi/chi"
+	"github.com/go-chi/chi/v5"
 	"github.com/porter-dev/porter/api/server/handlers/namespace"
 	"github.com/porter-dev/porter/api/server/handlers/namespace"
 	"github.com/porter-dev/porter/api/server/handlers/release"
 	"github.com/porter-dev/porter/api/server/handlers/release"
 	v1Release "github.com/porter-dev/porter/api/server/handlers/v1/release"
 	v1Release "github.com/porter-dev/porter/api/server/handlers/v1/release"

+ 1 - 1
api/server/router/v1/stack.go

@@ -1,7 +1,7 @@
 package v1
 package v1
 
 
 import (
 import (
-	"github.com/go-chi/chi"
+	"github.com/go-chi/chi/v5"
 	"github.com/porter-dev/porter/api/server/handlers/stack"
 	"github.com/porter-dev/porter/api/server/handlers/stack"
 	"github.com/porter-dev/porter/api/server/shared"
 	"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/config"

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

@@ -10,7 +10,7 @@ import (
 	"strings"
 	"strings"
 	"testing"
 	"testing"
 
 
-	"github.com/go-chi/chi"
+	"github.com/go-chi/chi/v5"
 	"github.com/porter-dev/porter/api/server/shared"
 	"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/apierrors"
 	"github.com/porter-dev/porter/api/server/shared/config"
 	"github.com/porter-dev/porter/api/server/shared/config"

+ 1 - 1
api/server/shared/requestutils/url_param.go

@@ -5,7 +5,7 @@ import (
 	"net/http"
 	"net/http"
 	"strconv"
 	"strconv"
 
 
-	"github.com/go-chi/chi"
+	"github.com/go-chi/chi/v5"
 	"github.com/porter-dev/porter/api/server/shared/apierrors"
 	"github.com/porter-dev/porter/api/server/shared/apierrors"
 	"github.com/porter-dev/porter/api/types"
 	"github.com/porter-dev/porter/api/types"
 )
 )

+ 1 - 1
api/server/shared/requestutils/url_param_test.go

@@ -6,7 +6,7 @@ import (
 	"net/http/httptest"
 	"net/http/httptest"
 	"testing"
 	"testing"
 
 
-	"github.com/go-chi/chi"
+	"github.com/go-chi/chi/v5"
 	"github.com/porter-dev/porter/api/server/shared/apierrors"
 	"github.com/porter-dev/porter/api/server/shared/apierrors"
 	"github.com/porter-dev/porter/api/server/shared/apitest"
 	"github.com/porter-dev/porter/api/server/shared/apitest"
 	"github.com/porter-dev/porter/api/server/shared/requestutils"
 	"github.com/porter-dev/porter/api/server/shared/requestutils"

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

@@ -3,7 +3,7 @@ package router
 import (
 import (
 	"net/http"
 	"net/http"
 
 
-	"github.com/go-chi/chi"
+	"github.com/go-chi/chi/v5"
 	"github.com/porter-dev/porter/api/server/shared"
 	"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/config"
 	"github.com/porter-dev/porter/api/types"
 	"github.com/porter-dev/porter/api/types"

+ 16 - 3
api/types/incident.go

@@ -112,6 +112,19 @@ type GetLogRequest struct {
 	Direction   string     `schema:"direction"`
 	Direction   string     `schema:"direction"`
 }
 }
 
 
+// You may either provide the pod selector directly, or the chart name,
+// in which case we will attempt to find the correct pod within the timeframe.
+type GetChartLogsWithinTimeRangeRequest struct {
+	ChartName   string    `schema:"chart_name"`
+	Limit       uint      `schema:"limit"`
+	StartRange  time.Time `schema:"start_range,omitempty"`
+	EndRange    time.Time `schema:"end_range,omitempty"`
+	SearchParam string    `schema:"search_param"`
+	Revision    string    `schema:"revision"`
+	Namespace   string    `schema:"namespace"`
+	PodSelector string    `schema:"pod_selector"`
+}
+
 type GetPodValuesRequest struct {
 type GetPodValuesRequest struct {
 	StartRange  *time.Time `schema:"start_range"`
 	StartRange  *time.Time `schema:"start_range"`
 	EndRange    *time.Time `schema:"end_range"`
 	EndRange    *time.Time `schema:"end_range"`
@@ -132,9 +145,9 @@ type LogLine struct {
 }
 }
 
 
 type GetLogResponse struct {
 type GetLogResponse struct {
-	BackwardContinueTime *time.Time `json:"backward_continue_time"`
-	ForwardContinueTime  *time.Time `json:"forward_continue_time"`
-	Logs                 []LogLine  `json:"logs"`
+	BackwardContinueTime *time.Time `json:"backward_continue_time,omitempty"`
+	ForwardContinueTime  *time.Time `json:"forward_continue_time,omitempty"`
+	Logs                 []LogLine  `json:"logs,omitempty"`
 }
 }
 
 
 type GetKubernetesEventRequest struct {
 type GetKubernetesEventRequest struct {

+ 21 - 1
api/types/porter_app.go

@@ -58,6 +58,10 @@ type UpdatePorterAppRequest struct {
 	PullRequestURL string `json:"pull_request_url"`
 	PullRequestURL string `json:"pull_request_url"`
 }
 }
 
 
+type RollbackPorterAppRequest struct {
+	Revision int `json:"revision" form:"required"`
+}
+
 type ListPorterAppResponse []*PorterApp
 type ListPorterAppResponse []*PorterApp
 
 
 // PorterAppEvent represents an event that occurs on a Porter stack during a stacks lifecycle.
 // PorterAppEvent represents an event that occurs on a Porter stack during a stacks lifecycle.
@@ -74,7 +78,7 @@ type PorterAppEvent struct {
 	// UpdatedAt is the time (UTC) that an event was last updated. This can occur when an event was created as PROGRESSING, then was marked as SUCCESSFUL for example
 	// UpdatedAt is the time (UTC) that an event was last updated. This can occur when an event was created as PROGRESSING, then was marked as SUCCESSFUL for example
 	UpdatedAt time.Time `json:"updated_at"`
 	UpdatedAt time.Time `json:"updated_at"`
 	// PorterAppID is the ID that the given event relates to
 	// PorterAppID is the ID that the given event relates to
-	PorterAppID string         `json:"porter_app_id"`
+	PorterAppID uint           `json:"porter_app_id"`
 	Metadata    map[string]any `json:"metadata,omitempty"`
 	Metadata    map[string]any `json:"metadata,omitempty"`
 }
 }
 
 
@@ -86,6 +90,22 @@ const (
 	PorterAppEventType_Build PorterAppEventType = "BUILD"
 	PorterAppEventType_Build PorterAppEventType = "BUILD"
 	// PorterAppEventType_Deploy represents a Porter Stack Deploy event which occurred through the Porter UI or CLI
 	// PorterAppEventType_Deploy represents a Porter Stack Deploy event which occurred through the Porter UI or CLI
 	PorterAppEventType_Deploy PorterAppEventType = "DEPLOY"
 	PorterAppEventType_Deploy PorterAppEventType = "DEPLOY"
+	// PorterAppEventType_PreDeploy represents a Porter Stack Pre-deploy event which occurred through the Porter UI or CLI
+	PorterAppEventType_PreDeploy PorterAppEventType = "PRE_DEPLOY"
 	// PorterAppEventType_AppEvent represents a Porter Stack App Event which occurred whilst the application was running, such as an OutOfMemory (OOM) error
 	// PorterAppEventType_AppEvent represents a Porter Stack App Event which occurred whilst the application was running, such as an OutOfMemory (OOM) error
 	PorterAppEventType_AppEvent PorterAppEventType = "APP_EVENT"
 	PorterAppEventType_AppEvent PorterAppEventType = "APP_EVENT"
 )
 )
+
+// PorterAppEvent represents a simplified event for creating a Porter stack app event
+// swagger:model
+type CreateOrUpdatePorterAppEventRequest struct {
+	// ID, if supplied, will be assumed to be an update event
+	ID string `json:"id"`
+	// Status contains the accepted status' of a given event such as SUCCESS, FAILED, PROGRESSING, etc.
+	Status string `json:"status,omitempty"`
+	// Type represents a supported Porter Stack Event
+	Type PorterAppEventType `json:"type"`
+	// TypeExternalSource represents an external event source such as Github, or Gitlab. This is not always required but will commonly be see in build events
+	TypeExternalSource string         `json:"type_source,omitempty"`
+	Metadata           map[string]any `json:"metadata,omitempty"`
+}

+ 163 - 31
cli/cmd/apply.go

@@ -3,6 +3,7 @@ package cmd
 import (
 import (
 	"context"
 	"context"
 	"encoding/json"
 	"encoding/json"
+	"errors"
 	"fmt"
 	"fmt"
 	"io/ioutil"
 	"io/ioutil"
 	"net/url"
 	"net/url"
@@ -10,6 +11,7 @@ import (
 	"path/filepath"
 	"path/filepath"
 	"strconv"
 	"strconv"
 	"strings"
 	"strings"
+	"time"
 
 
 	"github.com/cli/cli/git"
 	"github.com/cli/cli/git"
 	"github.com/fatih/color"
 	"github.com/fatih/color"
@@ -106,7 +108,7 @@ func init() {
 	applyCmd.MarkFlagRequired("file")
 	applyCmd.MarkFlagRequired("file")
 }
 }
 
 
-func apply(_ *types.GetAuthenticatedUserResponse, client *api.Client, _ []string) error {
+func apply(_ *types.GetAuthenticatedUserResponse, client *api.Client, _ []string) (err error) {
 	fileBytes, err := ioutil.ReadFile(porterYAML)
 	fileBytes, err := ioutil.ReadFile(porterYAML)
 	if err != nil {
 	if err != nil {
 		stackName := os.Getenv("PORTER_STACK_NAME")
 		stackName := os.Getenv("PORTER_STACK_NAME")
@@ -177,49 +179,120 @@ func apply(_ *types.GetAuthenticatedUserResponse, client *api.Client, _ []string
 			Builder:              builder,
 			Builder:              builder,
 		}
 		}
 		worker.RegisterHook("deploy-stack", deployStackHook)
 		worker.RegisterHook("deploy-stack", deployStackHook)
+
+		if os.Getenv("GITHUB_RUN_ID") != "" {
+			// Create app event to signfy start of build
+			req := &types.CreateOrUpdatePorterAppEventRequest{
+				Status:             "PROGRESSING",
+				Type:               types.PorterAppEventType_Build,
+				TypeExternalSource: "GITHUB",
+				Metadata: map[string]any{
+					"action_run_id": os.Getenv("GITHUB_RUN_ID"),
+					"org":           os.Getenv("GITHUB_REPOSITORY_OWNER"),
+				},
+			}
+
+			repoNameSplit := strings.Split(os.Getenv("GITHUB_REPOSITORY"), "/")
+			if len(repoNameSplit) != 2 {
+				return fmt.Errorf("unable to parse GITHUB_REPOSITORY")
+			}
+			req.Metadata["repo"] = repoNameSplit[1]
+
+			actionRunID := os.Getenv("GITHUB_RUN_ID")
+			if actionRunID != "" {
+				arid, err := strconv.Atoi(actionRunID)
+				if err != nil {
+					return fmt.Errorf("unable to parse GITHUB_RUN_ID as int: %w", err)
+				}
+				req.Metadata["action_run_id"] = arid
+			}
+
+			repoOwnerAccountID := os.Getenv("GITHUB_REPOSITORY_OWNER_ID")
+			if repoOwnerAccountID != "" {
+				arid, err := strconv.Atoi(repoOwnerAccountID)
+				if err != nil {
+					return fmt.Errorf("unable to parse GITHUB_REPOSITORY_OWNER_ID as int: %w", err)
+				}
+				req.Metadata["github_account_id"] = arid
+			}
+
+			ctx := context.Background()
+			_, err := client.CreateOrUpdatePorterAppEvent(ctx, cliConf.Project, cliConf.Cluster, stackName, req)
+			if err != nil {
+				return fmt.Errorf("unable to create porter app build event: %w", err)
+			}
+		}
 	} else {
 	} else {
 		return fmt.Errorf("unknown porter.yaml version: %s", previewVersion.Version)
 		return fmt.Errorf("unknown porter.yaml version: %s", previewVersion.Version)
 	}
 	}
 
 
 	basePath, err := os.Getwd()
 	basePath, err := os.Getwd()
 	if err != nil {
 	if err != nil {
-		return fmt.Errorf("error getting working directory: %w", err)
+		err = fmt.Errorf("error getting working directory: %w", err)
+		return
+	}
+
+	drivers := []struct {
+		name     string
+		funcName func(resource *switchboardModels.Resource, opts *drivers.SharedDriverOpts) (drivers.Driver, error)
+	}{
+		{"deploy", NewDeployDriver},
+		{"build-image", preview.NewBuildDriver},
+		{"push-image", preview.NewPushDriver},
+		{"update-config", preview.NewUpdateConfigDriver},
+		{"random-string", preview.NewRandomStringDriver},
+		{"env-group", preview.NewEnvGroupDriver},
+		{"os-env", preview.NewOSEnvDriver},
+	}
+	for _, driver := range drivers {
+		err = worker.RegisterDriver(driver.name, driver.funcName)
+		if err != nil {
+			err = fmt.Errorf("error registering driver %s: %w", driver.name, err)
+			return
+		}
 	}
 	}
 
 
-	worker.RegisterDriver("deploy", NewDeployDriver)
-	worker.RegisterDriver("build-image", preview.NewBuildDriver)
-	worker.RegisterDriver("push-image", preview.NewPushDriver)
-	worker.RegisterDriver("update-config", preview.NewUpdateConfigDriver)
-	worker.RegisterDriver("random-string", preview.NewRandomStringDriver)
-	worker.RegisterDriver("env-group", preview.NewEnvGroupDriver)
-	worker.RegisterDriver("os-env", preview.NewOSEnvDriver)
-
 	worker.SetDefaultDriver("deploy")
 	worker.SetDefaultDriver("deploy")
 
 
 	if hasDeploymentHookEnvVars() {
 	if hasDeploymentHookEnvVars() {
 		deplNamespace := os.Getenv("PORTER_NAMESPACE")
 		deplNamespace := os.Getenv("PORTER_NAMESPACE")
 
 
 		if deplNamespace == "" {
 		if deplNamespace == "" {
-			return fmt.Errorf("namespace must be set by PORTER_NAMESPACE")
+			err = fmt.Errorf("namespace must be set by PORTER_NAMESPACE")
+			return
 		}
 		}
 
 
 		deploymentHook, err := NewDeploymentHook(client, resGroup, deplNamespace)
 		deploymentHook, err := NewDeploymentHook(client, resGroup, deplNamespace)
 		if err != nil {
 		if err != nil {
-			return fmt.Errorf("error creating deployment hook: %w", err)
+			err = fmt.Errorf("error creating deployment hook: %w", err)
+			return err
 		}
 		}
 
 
-		worker.RegisterHook("deployment", deploymentHook)
+		err = worker.RegisterHook("deployment", deploymentHook)
+		if err != nil {
+			err = fmt.Errorf("error registering deployment hook: %w", err)
+			return err
+		}
 	}
 	}
 
 
 	errorEmitterHook := NewErrorEmitterHook(client, resGroup)
 	errorEmitterHook := NewErrorEmitterHook(client, resGroup)
-	worker.RegisterHook("erroremitter", errorEmitterHook)
+	err = worker.RegisterHook("erroremitter", errorEmitterHook)
+	if err != nil {
+		err = fmt.Errorf("error registering error emitter hook: %w", err)
+		return err
+	}
 
 
 	cloneEnvGroupHook := NewCloneEnvGroupHook(client, resGroup)
 	cloneEnvGroupHook := NewCloneEnvGroupHook(client, resGroup)
-	worker.RegisterHook("cloneenvgroup", cloneEnvGroupHook)
+	err = worker.RegisterHook("cloneenvgroup", cloneEnvGroupHook)
+	if err != nil {
+		err = fmt.Errorf("error registering clone env group hook: %w", err)
+		return err
+	}
 
 
-	return worker.Apply(resGroup, &switchboardTypes.ApplyOpts{
+	err = worker.Apply(resGroup, &switchboardTypes.ApplyOpts{
 		BasePath: basePath,
 		BasePath: basePath,
 	})
 	})
+	return
 }
 }
 
 
 func applyValidate() error {
 func applyValidate() error {
@@ -316,10 +389,11 @@ func (d *DeployDriver) ShouldApply(_ *switchboardModels.Resource) bool {
 }
 }
 
 
 func (d *DeployDriver) Apply(resource *switchboardModels.Resource) (*switchboardModels.Resource, error) {
 func (d *DeployDriver) Apply(resource *switchboardModels.Resource) (*switchboardModels.Resource, error) {
+	ctx := context.Background()
 	client := config.GetAPIClient()
 	client := config.GetAPIClient()
 
 
 	_, err := client.GetRelease(
 	_, err := client.GetRelease(
-		context.Background(),
+		ctx,
 		d.target.Project,
 		d.target.Project,
 		d.target.Cluster,
 		d.target.Cluster,
 		d.target.Namespace,
 		d.target.Namespace,
@@ -333,7 +407,7 @@ func (d *DeployDriver) Apply(resource *switchboardModels.Resource) (*switchboard
 	}
 	}
 
 
 	if d.source.IsApplication {
 	if d.source.IsApplication {
-		return d.applyApplication(resource, client, shouldCreate)
+		return d.applyApplication(ctx, resource, client, shouldCreate)
 	}
 	}
 
 
 	return d.applyAddon(resource, client, shouldCreate)
 	return d.applyAddon(resource, client, shouldCreate)
@@ -394,7 +468,7 @@ func (d *DeployDriver) applyAddon(resource *switchboardModels.Resource, client *
 	return resource, nil
 	return resource, nil
 }
 }
 
 
-func (d *DeployDriver) applyApplication(resource *switchboardModels.Resource, client *api.Client, shouldCreate bool) (*switchboardModels.Resource, error) {
+func (d *DeployDriver) applyApplication(ctx context.Context, resource *switchboardModels.Resource, client *api.Client, shouldCreate bool) (*switchboardModels.Resource, error) {
 	if resource == nil {
 	if resource == nil {
 		return nil, fmt.Errorf("nil resource")
 		return nil, fmt.Errorf("nil resource")
 	}
 	}
@@ -484,29 +558,87 @@ func (d *DeployDriver) applyApplication(resource *switchboardModels.Resource, cl
 	if d.source.Name == "job" && appConfig.WaitForJob && (shouldCreate || !appConfig.OnlyCreate) {
 	if d.source.Name == "job" && appConfig.WaitForJob && (shouldCreate || !appConfig.OnlyCreate) {
 		color.New(color.FgYellow).Printf("Waiting for job '%s' to finish\n", resourceName)
 		color.New(color.FgYellow).Printf("Waiting for job '%s' to finish\n", resourceName)
 
 
+		var predeployEventResponseID string
+
+		stackNameWithoutRelease := strings.TrimSuffix(d.target.AppName, "-r")
+
+		if strings.Contains(d.target.Namespace, "porter-stack-") {
+			eventRequest := types.CreateOrUpdatePorterAppEventRequest{
+				Status: "PROGRESSING",
+				Type:   types.PorterAppEventType_PreDeploy,
+				Metadata: map[string]any{
+					"start_time": time.Now().UTC(),
+				},
+			}
+			eventResponse, err := client.CreateOrUpdatePorterAppEvent(ctx, d.target.Project, d.target.Cluster, stackNameWithoutRelease, &eventRequest)
+			if err != nil {
+				return nil, fmt.Errorf("error creating porter app event for pre-deploy job: %s", err.Error())
+			}
+			predeployEventResponseID = eventResponse.ID
+		}
+
 		err = wait.WaitForJob(client, &wait.WaitOpts{
 		err = wait.WaitForJob(client, &wait.WaitOpts{
 			ProjectID: d.target.Project,
 			ProjectID: d.target.Project,
 			ClusterID: d.target.Cluster,
 			ClusterID: d.target.Cluster,
 			Namespace: d.target.Namespace,
 			Namespace: d.target.Namespace,
 			Name:      resourceName,
 			Name:      resourceName,
 		})
 		})
+		if err != nil {
+			if strings.Contains(d.target.Namespace, "porter-stack-") {
+				if predeployEventResponseID == "" {
+					return nil, errors.New("unable to find pre-deploy event response ID for failed pre-deploy event")
+				}
 
 
-		if err != nil && appConfig.OnlyCreate {
-			deleteJobErr := client.DeleteRelease(
-				context.Background(),
-				d.target.Project,
-				d.target.Cluster,
-				d.target.Namespace,
-				resourceName,
-			)
+				eventRequest := types.CreateOrUpdatePorterAppEventRequest{
+					ID:     predeployEventResponseID,
+					Status: "FAILED",
+					Type:   types.PorterAppEventType_PreDeploy,
+					Metadata: map[string]any{
+						"end_time": time.Now().UTC(),
+					},
+				}
+				_, err := client.CreateOrUpdatePorterAppEvent(ctx, d.target.Project, d.target.Cluster, stackNameWithoutRelease, &eventRequest)
+				if err != nil {
+					return nil, fmt.Errorf("error updating failed porter app event for pre-deploy job: %s", err.Error())
+				}
+			}
 
 
-			if deleteJobErr != nil {
-				return nil, fmt.Errorf("error deleting job %s with waitForJob and onlyCreate set to true: %w",
-					resourceName, deleteJobErr)
+			if appConfig.OnlyCreate {
+				deleteJobErr := client.DeleteRelease(
+					context.Background(),
+					d.target.Project,
+					d.target.Cluster,
+					d.target.Namespace,
+					resourceName,
+				)
+
+				if deleteJobErr != nil {
+					return nil, fmt.Errorf("error deleting job %s with waitForJob and onlyCreate set to true: %w",
+						resourceName, deleteJobErr)
+				}
 			}
 			}
-		} else if err != nil {
 			return nil, fmt.Errorf("error waiting for job %s: %w", resourceName, err)
 			return nil, fmt.Errorf("error waiting for job %s: %w", resourceName, err)
 		}
 		}
+
+		if strings.Contains(d.target.Namespace, "porter-stack-") {
+			stackNameWithoutRelease := strings.TrimSuffix(d.target.AppName, "-r")
+			if predeployEventResponseID == "" {
+				return nil, errors.New("unable to find pre-deploy event response ID for successful pre-deploy event")
+			}
+			eventRequest := types.CreateOrUpdatePorterAppEventRequest{
+				ID:     predeployEventResponseID,
+				Status: "SUCCESS",
+				Type:   types.PorterAppEventType_PreDeploy,
+				Metadata: map[string]any{
+					"end_time": time.Now().UTC(),
+				},
+			}
+			_, err := client.CreateOrUpdatePorterAppEvent(ctx, d.target.Project, d.target.Cluster, stackNameWithoutRelease, &eventRequest)
+			if err != nil {
+				return nil, fmt.Errorf("error updating successful porter app event for pre-deploy job: %s", err.Error())
+			}
+		}
+
 	}
 	}
 
 
 	return resource, err
 	return resource, err

+ 4 - 3
cli/cmd/preview/update_config_driver.go

@@ -56,6 +56,8 @@ func (d *UpdateConfigDriver) ShouldApply(resource *models.Resource) bool {
 }
 }
 
 
 func (d *UpdateConfigDriver) Apply(resource *models.Resource) (*models.Resource, error) {
 func (d *UpdateConfigDriver) Apply(resource *models.Resource) (*models.Resource, error) {
+	ctx := context.Background()
+
 	updateConfigDriverConfig, err := d.getConfig(resource)
 	updateConfigDriverConfig, err := d.getConfig(resource)
 	if err != nil {
 	if err != nil {
 		return nil, err
 		return nil, err
@@ -66,7 +68,7 @@ func (d *UpdateConfigDriver) Apply(resource *models.Resource) (*models.Resource,
 	client := config.GetAPIClient()
 	client := config.GetAPIClient()
 
 
 	_, err = client.GetRelease(
 	_, err = client.GetRelease(
-		context.Background(),
+		ctx,
 		d.target.Project,
 		d.target.Project,
 		d.target.Cluster,
 		d.target.Cluster,
 		d.target.Namespace,
 		d.target.Namespace,
@@ -146,7 +148,7 @@ func (d *UpdateConfigDriver) Apply(resource *models.Resource) (*models.Resource,
 		}
 		}
 
 
 		err = client.CreateRepository(
 		err = client.CreateRepository(
-			context.Background(),
+			ctx,
 			sharedOpts.ProjectID,
 			sharedOpts.ProjectID,
 			regID,
 			regID,
 			&types.CreateRegistryRepositoryRequest{
 			&types.CreateRegistryRepositoryRequest{
@@ -190,7 +192,6 @@ func (d *UpdateConfigDriver) Apply(resource *models.Resource) (*models.Resource,
 			Namespace: d.target.Namespace,
 			Namespace: d.target.Namespace,
 			Name:      d.target.AppName,
 			Name:      d.target.AppName,
 		})
 		})
-
 		if err != nil {
 		if err != nil {
 			return nil, err
 			return nil, err
 		}
 		}

+ 9 - 0
cmd/app/main.go

@@ -3,6 +3,7 @@
 package main
 package main
 
 
 import (
 import (
+	"context"
 	"errors"
 	"errors"
 	"flag"
 	"flag"
 	"fmt"
 	"fmt"
@@ -10,6 +11,8 @@ import (
 	"net/http"
 	"net/http"
 	"os"
 	"os"
 
 
+	"github.com/porter-dev/porter/internal/telemetry"
+
 	"github.com/porter-dev/porter/api/server/router"
 	"github.com/porter-dev/porter/api/server/router"
 	"github.com/porter-dev/porter/api/server/shared/config"
 	"github.com/porter-dev/porter/api/server/shared/config"
 	"github.com/porter-dev/porter/api/server/shared/config/loader"
 	"github.com/porter-dev/porter/api/server/shared/config/loader"
@@ -61,6 +64,12 @@ func main() {
 		IdleTimeout:  config.ServerConf.TimeoutIdle,
 		IdleTimeout:  config.ServerConf.TimeoutIdle,
 	}
 	}
 
 
+	// ignore error so that telemetry is not required
+	tracer, err := telemetry.InitTracer(context.Background(), config.TelemetryConfig)
+	if err == nil {
+		defer tracer.Shutdown()
+	}
+
 	if err := s.ListenAndServe(); err != nil && err != http.ErrServerClosed {
 	if err := s.ListenAndServe(); err != nil && err != http.ErrServerClosed {
 		config.Logger.Fatal().Err(err).Msg("Server startup failed")
 		config.Logger.Fatal().Err(err).Msg("Server startup failed")
 	}
 	}

+ 1 - 1
cmd/dev/main.go

@@ -5,7 +5,7 @@ import (
 	"net/http"
 	"net/http"
 	"strings"
 	"strings"
 
 
-	"github.com/go-chi/chi"
+	"github.com/go-chi/chi/v5"
 	"github.com/porter-dev/porter/api/server/router"
 	"github.com/porter-dev/porter/api/server/router"
 	"github.com/porter-dev/porter/api/server/shared/apitest"
 	"github.com/porter-dev/porter/api/server/shared/apitest"
 )
 )

Разница между файлами не показана из-за своего большого размера
+ 525 - 36
dashboard/package-lock.json


+ 2 - 0
dashboard/package.json

@@ -40,6 +40,7 @@
     "ini": ">=1.3.6",
     "ini": ">=1.3.6",
     "js-base64": "^3.6.0",
     "js-base64": "^3.6.0",
     "js-yaml": "^4.1.0",
     "js-yaml": "^4.1.0",
+    "jszip": "^3.10.1",
     "lodash": "^4.17.21",
     "lodash": "^4.17.21",
     "markdown-to-jsx": "^7.0.1",
     "markdown-to-jsx": "^7.0.1",
     "qs": "^6.9.4",
     "qs": "^6.9.4",
@@ -49,6 +50,7 @@
     "react-animate-height": "^3.1.1",
     "react-animate-height": "^3.1.1",
     "react-color": "^2.19.3",
     "react-color": "^2.19.3",
     "react-datepicker": "^4.8.0",
     "react-datepicker": "^4.8.0",
+    "react-diff-viewer": "^3.1.1",
     "react-dom": "^18.0.0",
     "react-dom": "^18.0.0",
     "react-error-boundary": "^3.1.3",
     "react-error-boundary": "^3.1.3",
     "react-hot-toast": "^2.4.0",
     "react-hot-toast": "^2.4.0",

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

@@ -0,0 +1,3 @@
+<svg width="24" height="24" viewBox="0 0 24 24" fill="white" 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" strokeLinejoin="round"/>
+</svg>

+ 1 - 1
dashboard/src/assets/filter-outline.svg

@@ -1,3 +1,3 @@
 <svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
 <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" strokeLinejoin="round"/>
+    <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" strokeLinejoin="round"/>
 </svg>
 </svg>

+ 3 - 0
dashboard/src/assets/save-01.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 d="M6.60002 20.3999V14.9999C6.60002 14.3372 7.13728 13.7999 7.80002 13.7999H16.2C16.8628 13.7999 17.4 14.3372 17.4 14.9999V20.9999M15 7.1999L7.80002 7.1999C7.13728 7.1999 6.60002 6.66264 6.60002 5.9999L6.60002 2.3999M20.9975 6.59737L17.4026 3.00243C17.0168 2.61664 16.4935 2.39991 15.9479 2.3999H4.45717C3.32102 2.3999 2.40002 3.3209 2.40002 4.45705V19.5428C2.40002 20.6789 3.32102 21.5999 4.45717 21.5999H19.5429C20.679 21.5999 21.6 20.6789 21.6 19.5428V8.05199C21.6 7.5064 21.3833 6.98316 20.9975 6.59737Z" stroke="white" stroke-width="2" stroke-linecap="round"/>
+</svg>

+ 5 - 1
dashboard/src/components/LogQueryModeSelectionToggle.tsx

@@ -7,6 +7,7 @@ import styled from "styled-components";
 interface LogQueryModeSelectionToggleProps {
 interface LogQueryModeSelectionToggleProps {
   selectedDate?: Date;
   selectedDate?: Date;
   setSelectedDate: React.Dispatch<React.SetStateAction<Date>>;
   setSelectedDate: React.Dispatch<React.SetStateAction<Date>>;
+  resetSearch: () => void;
 }
 }
 
 
 const LogQueryModeSelectionToggle = (
 const LogQueryModeSelectionToggle = (
@@ -22,7 +23,10 @@ const LogQueryModeSelectionToggle = (
     >
     >
       <ToggleButton>
       <ToggleButton>
         <ToggleOption
         <ToggleOption
-          onClick={() => props.setSelectedDate(undefined)}
+          onClick={() => {
+            props.setSelectedDate(undefined);
+            props.resetSearch();
+          }}
           selected={!props.selectedDate}
           selected={!props.selectedDate}
         >
         >
           <Dot selected={!props.selectedDate} />
           <Dot selected={!props.selectedDate} />

+ 11 - 3
dashboard/src/components/LogSearchBar.tsx

@@ -1,18 +1,25 @@
 import React, { useState } from "react";
 import React, { useState } from "react";
 import Button from "./Button";
 import Button from "./Button";
 import styled from "styled-components";
 import styled from "styled-components";
+import dayjs from "dayjs";
 
 
 interface Props {
 interface Props {
+  searchText: string;
+  setSearchText: (x: string) => void;
   setEnteredSearchText: (x: string) => void;
   setEnteredSearchText: (x: string) => void;
+  setSelectedDate: () => void;
 }
 }
 
 
 const escapeRegExp = (str: string) => {
 const escapeRegExp = (str: string) => {
   return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
   return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
 };
 };
 
 
-const LogSearchBar: React.FC<Props> = ({ setEnteredSearchText }) => {
-  const [searchText, setSearchText] = useState("");
-
+const LogSearchBar: React.FC<Props> = ({
+  searchText,
+  setSearchText,
+  setEnteredSearchText,
+  setSelectedDate,
+}) => {
   return (
   return (
     <SearchRowWrapper>
     <SearchRowWrapper>
       <SearchBarWrapper>
       <SearchBarWrapper>
@@ -25,6 +32,7 @@ const LogSearchBar: React.FC<Props> = ({ setEnteredSearchText }) => {
           onKeyPress={(event) => {
           onKeyPress={(event) => {
             if (event.key === "Enter") {
             if (event.key === "Enter") {
               setEnteredSearchText(escapeRegExp(searchText));
               setEnteredSearchText(escapeRegExp(searchText));
+              setSelectedDate();
             }
             }
           }}
           }}
           placeholder="Search logs..."
           placeholder="Search logs..."

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

@@ -131,9 +131,9 @@ const ProvisionerFlow: React.FC<Props> = ({
               Separate from the AWS cost, Porter charges based on the amount of resources that are being used.
               Separate from the AWS cost, Porter charges based on the amount of resources that are being used.
             </Text>
             </Text>
             <Spacer inline width="5px" />
             <Spacer inline width="5px" />
-            <Link hasunderline to="https://porter.run/pricing">
+            <Link hasunderline to="https://porter.run/pricing" target="_blank">
               Learn more about our pricing
               Learn more about our pricing
-            </Link>.
+            </Link>
             <Spacer y={1} />
             <Spacer y={1} />
             <Text color="helper">
             <Text color="helper">
               All AWS resources will be automatically deleted when you delete your Porter project. Please enter the AWS base cost ("315.94") below to proceed:
               All AWS resources will be automatically deleted when you delete your Porter project. Please enter the AWS base cost ("315.94") below to proceed:

+ 10 - 1
dashboard/src/components/RadioFilter.tsx

@@ -95,7 +95,7 @@ const RadioFilter: React.FC<Props> = (props) => {
         <TextAlt>{props.name}</TextAlt>
         <TextAlt>{props.name}</TextAlt>
         <Bar />
         <Bar />
         <Selected>
         <Selected>
-          {props.selected
+          {props.selected != null
             ? props.selected === ""
             ? props.selected === ""
               ? "All"
               ? "All"
               : getLabel(props.selected)
               : getLabel(props.selected)
@@ -126,6 +126,15 @@ const Selected = styled.div`
   max-width: 120px;
   max-width: 120px;
 `;
 `;
 
 
+const DimmedText = styled.div`
+  overflow: hidden;
+  white-space: nowrap;
+  text-overflow: ellipsis;
+  word-break: anywhere;
+  margin-right: 10px;
+  color: #999;
+`;
+
 const Text = styled.div`
 const Text = styled.div`
   overflow: hidden;
   overflow: hidden;
   white-space: nowrap;
   white-space: nowrap;

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

@@ -12,6 +12,27 @@ type Props = {
 };
 };
 
 
 const DateTimePicker: React.FC<Props> = ({ startDate, setStartDate }) => {
 const DateTimePicker: React.FC<Props> = ({ startDate, setStartDate }) => {
+  const maxDate = new Date();
+  const minDate = new Date(maxDate.getTime() - 7 * 24 * 60 * 60 * 1000);
+
+  const minTimeMaxDay = new Date(maxDate);
+  minTimeMaxDay.setHours(0, 0, 0, 0);
+  const maxTimeMinDay = new Date(minDate);
+  maxTimeMinDay.setHours(23, 59, 0, 0);
+
+  const availableDates = [];
+  let currentDate = new Date(minDate);
+  while (currentDate <= maxDate) {
+    availableDates.push(new Date(currentDate));
+    currentDate.setTime(currentDate.getTime() + 24 * 60 * 60 * 1000);
+  }
+
+  const isMinDay = startDate.toDateString() === minDate.toDateString();
+  const isMaxDay = startDate.toDateString() === maxDate.toDateString();
+
+  const minTime = isMinDay ? minDate : isMaxDay ? minTimeMaxDay : null;
+  const maxTime = isMaxDay ? maxDate : isMinDay ? maxTimeMinDay : null;
+
   return (
   return (
     <DateTimePickerWrapper
     <DateTimePickerWrapper
       onClick={(e) => {
       onClick={(e) => {
@@ -25,6 +46,9 @@ const DateTimePicker: React.FC<Props> = ({ startDate, setStartDate }) => {
         onChange={(date: any) => setStartDate(date)}
         onChange={(date: any) => setStartDate(date)}
         showTimeSelect
         showTimeSelect
         dateFormat="MMMM d, yyyy h:mm aa"
         dateFormat="MMMM d, yyyy h:mm aa"
+        includeDates={availableDates}
+        maxTime={maxTime}
+        minTime={minTime}
       />
       />
     </DateTimePickerWrapper>
     </DateTimePickerWrapper>
   );
   );

+ 22 - 3
dashboard/src/components/date-time-picker/react-datepicker.css

@@ -1,3 +1,15 @@
+.react-datepicker__aria-live {
+  position: absolute;
+  clip-path: circle(0);
+  border: 0;
+  height: 1px;
+  margin: -1px;
+  overflow: hidden;
+  padding: 0;
+  width: 1px;
+  white-space: nowrap;
+}
+
 .react-datepicker__triangle {
 .react-datepicker__triangle {
   display: none;
   display: none;
 }
 }
@@ -358,7 +370,8 @@
   ul.react-datepicker__time-list
   ul.react-datepicker__time-list
   li.react-datepicker__time-list-item:hover {
   li.react-datepicker__time-list-item:hover {
   cursor: pointer;
   cursor: pointer;
-  background-color: #26292e;
+  background-color: #525882;
+  border-radius: 0.3rem
 }
 }
 .react-datepicker__time-container
 .react-datepicker__time-container
   .react-datepicker__time
   .react-datepicker__time
@@ -368,6 +381,7 @@
   background-color: #949eff;
   background-color: #949eff;
   color: white;
   color: white;
   font-weight: bold;
   font-weight: bold;
+  border-radius: 0.3rem
 }
 }
 .react-datepicker__time-container
 .react-datepicker__time-container
   .react-datepicker__time
   .react-datepicker__time
@@ -406,6 +420,9 @@
   border-radius: 0.3rem;
   border-radius: 0.3rem;
   background-color: #26292e;
   background-color: #26292e;
 }
 }
+.react-datepicker__month {
+  cursor: default
+}
 .react-datepicker__day-names,
 .react-datepicker__day-names,
 .react-datepicker__week {
 .react-datepicker__week {
   white-space: nowrap;
   white-space: nowrap;
@@ -457,7 +474,7 @@
 .react-datepicker__month-text:hover,
 .react-datepicker__month-text:hover,
 .react-datepicker__quarter-text:hover {
 .react-datepicker__quarter-text:hover {
   border-radius: 0.3rem;
   border-radius: 0.3rem;
-  background-color: #26292e;
+  background-color: #525882;
 }
 }
 .react-datepicker__day--today,
 .react-datepicker__day--today,
 .react-datepicker__month-text--today,
 .react-datepicker__month-text--today,
@@ -539,7 +556,9 @@
 .react-datepicker__month-text--disabled,
 .react-datepicker__month-text--disabled,
 .react-datepicker__quarter-text--disabled {
 .react-datepicker__quarter-text--disabled {
   cursor: default;
   cursor: default;
-  color: #ccc;
+  color: #999;
+  background-color: transparent;
+  cursor: not-allowed;
 }
 }
 .react-datepicker__day--disabled:hover,
 .react-datepicker__day--disabled:hover,
 .react-datepicker__month-text--disabled:hover,
 .react-datepicker__month-text--disabled:hover,

+ 14 - 15
dashboard/src/components/porter/Checkbox.tsx

@@ -20,24 +20,22 @@ const Checkbox: React.FC<Props> = ({
   return (
   return (
     disabled && disabledTooltip ?
     disabled && disabledTooltip ?
       <Tooltip content={disabledTooltip} position="right">
       <Tooltip content={disabledTooltip} position="right">
-        <StyledCheckbox>
-          <Box
-            checked={checked}
-            onClick={disabled ? () => { } : toggleChecked}
-            disabled={disabled}
-          >
+        <StyledCheckbox 
+          onClick={disabled ? () => { } : toggleChecked}
+          disabled={disabled}
+        >
+          <Box checked={checked}>
             <i className="material-icons">done</i>
             <i className="material-icons">done</i>
           </Box>
           </Box>
           {children}
           {children}
         </StyledCheckbox>
         </StyledCheckbox>
       </Tooltip>
       </Tooltip>
       :
       :
-      <StyledCheckbox>
-        <Box
-          checked={checked}
-          onClick={disabled ? () => { } : toggleChecked}
-          disabled={disabled}
-        >
+      <StyledCheckbox 
+        onClick={disabled ? () => { } : toggleChecked}
+        disabled={disabled}
+      >
+        <Box checked={checked}>
           <i className="material-icons">done</i>
           <i className="material-icons">done</i>
         </Box>
         </Box>
         {children}
         {children}
@@ -47,14 +45,16 @@ const Checkbox: React.FC<Props> = ({
 
 
 export default Checkbox;
 export default Checkbox;
 
 
-const StyledCheckbox = styled.div`
+const StyledCheckbox = styled.div<{
+  disabled?: boolean;
+}>`
   display: flex;
   display: flex;
   align-items: center;
   align-items: center;
+  cursor: ${(props) => (props.disabled ? "not-allowed" : "pointer")};
 `;
 `;
 
 
 const Box = styled.div<{
 const Box = styled.div<{
   checked: boolean;
   checked: boolean;
-  disabled?: boolean;
 }>`
 }>`
   width: 12px;
   width: 12px;
   height: 12px;
   height: 12px;
@@ -65,7 +65,6 @@ const Box = styled.div<{
   display: flex;
   display: flex;
   align-items: center;
   align-items: center;
   justify-content: center;
   justify-content: center;
-  cursor: ${(props) => (props.disabled ? "not-allowed" : "pointer")};
 
 
   > i {
   > i {
     font-size: 12px;
     font-size: 12px;

+ 16 - 7
dashboard/src/components/porter/ConfirmOverlay.tsx

@@ -1,3 +1,4 @@
+import Loading from "components/Loading";
 import React from "react";
 import React from "react";
 import { createPortal } from "react-dom";
 import { createPortal } from "react-dom";
 import styled from "styled-components";
 import styled from "styled-components";
@@ -6,23 +7,31 @@ type Props = {
   message: string;
   message: string;
   onYes: React.MouseEventHandler;
   onYes: React.MouseEventHandler;
   onNo: React.MouseEventHandler;
   onNo: React.MouseEventHandler;
+  loading?: boolean;
 };
 };
 
 
-const TemplateComponent: React.FC<Props> = ({
+const ConfirmOverlay: React.FC<Props> = ({
   message,
   message,
   onYes,
   onYes,
   onNo,
   onNo,
+  loading,
 }) => {
 }) => {
   return (
   return (
     <>
     <>
       {
       {
         createPortal(
         createPortal(
           <StyledConfirmOverlay>
           <StyledConfirmOverlay>
-            {message}
-            <ButtonRow>
-              <ConfirmButton onClick={onYes}>Yes</ConfirmButton>
-              <ConfirmButton onClick={onNo}>No</ConfirmButton>
-            </ButtonRow>
+            {loading ? (
+              <Loading />
+            ) : (
+              <>
+                {message}
+                <ButtonRow>
+                  <ConfirmButton onClick={onYes}>Yes</ConfirmButton>
+                  <ConfirmButton onClick={onNo}>No</ConfirmButton>
+                </ButtonRow>
+              </>
+            )}
           </StyledConfirmOverlay>,
           </StyledConfirmOverlay>,
           document.body
           document.body
         )
         )
@@ -31,7 +40,7 @@ const TemplateComponent: React.FC<Props> = ({
   );
   );
 };
 };
 
 
-export default TemplateComponent;
+export default ConfirmOverlay;
 
 
 const StyledConfirmOverlay = styled.div`
 const StyledConfirmOverlay = styled.div`
   position: absolute;
   position: absolute;

+ 13 - 4
dashboard/src/components/porter/Container.tsx

@@ -4,13 +4,17 @@ import styled from "styled-components";
 type Props = {
 type Props = {
   children: React.ReactNode;
   children: React.ReactNode;
   row?: boolean;
   row?: boolean;
+  column?: boolean;
   spaced?: boolean;
   spaced?: boolean;
+  alignItems?: string;
 };
 };
 
 
 const Container: React.FC<Props> = ({
 const Container: React.FC<Props> = ({
   children,
   children,
   row,
   row,
   spaced,
   spaced,
+  column,
+  alignItems,
 }) => {
 }) => {
   const [isExpanded, setIsExpanded] = useState(false);
   const [isExpanded, setIsExpanded] = useState(false);
 
 
@@ -18,6 +22,8 @@ const Container: React.FC<Props> = ({
     <StyledContainer
     <StyledContainer
       spaced={spaced}
       spaced={spaced}
       row={row}
       row={row}
+      column={column}
+      alignItems={alignItems}
     >
     >
       {children}
       {children}
     </StyledContainer>
     </StyledContainer>
@@ -27,10 +33,13 @@ const Container: React.FC<Props> = ({
 export default Container;
 export default Container;
 
 
 const StyledContainer = styled.div<{
 const StyledContainer = styled.div<{
-  row: boolean;
-  spaced: boolean;
+  row?: boolean;
+  column?: boolean;
+  spaced?: boolean;
+  alignItems?: string
 }>`
 }>`
-  display: ${props => props.row ? "flex" : "block"};
-  align-items: center;
+  display: ${props => props.row || props.column ? "flex" : "block"};
+  flex-direction: ${props => props.row ? "row" : "column"};
+  align-items: ${props => props.alignItems ? props.alignItems : "center"};
   justify-content: ${props => props.spaced ? "space-between" : "flex-start"};
   justify-content: ${props => props.spaced ? "space-between" : "flex-start"};
 `;
 `;

+ 8 - 2
dashboard/src/components/porter/Icon.tsx

@@ -4,19 +4,25 @@ import styled from "styled-components";
 type Props = {
 type Props = {
   src: any;
   src: any;
   height?: string;
   height?: string;
+  opacity?: number;
 };
 };
 
 
 const Icon: React.FC<Props> = ({
 const Icon: React.FC<Props> = ({
   src,
   src,
   height,
   height,
+  opacity,
 }) => {
 }) => {
   return (
   return (
-    <StyledIcon src={src} height={height} />
+    <StyledIcon src={src} height={height} opacity={opacity} />
   );
   );
 };
 };
 
 
 export default Icon;
 export default Icon;
 
 
-const StyledIcon = styled.img<{ height?: string}>`
+const StyledIcon = styled.img<{ 
+  height?: string;
+  opacity?: number;
+}>`
   height: ${props => props.height || "20px"};
   height: ${props => props.height || "20px"};
+  opacity: ${props => props.opacity || 1};
 `;
 `;

+ 33 - 15
dashboard/src/components/porter/Link.tsx

@@ -2,6 +2,8 @@ import DynamicLink from "components/DynamicLink";
 import React, { useEffect, useState } from "react";
 import React, { useEffect, useState } from "react";
 import styled from "styled-components";
 import styled from "styled-components";
 
 
+import Icon from "components/porter/Icon";
+
 type Props = {
 type Props = {
   to?: string;
   to?: string;
   onClick?: () => void;
   onClick?: () => void;
@@ -18,35 +20,53 @@ const Link: React.FC<Props> = ({
   hasunderline,
   hasunderline,
 }) => {
 }) => {
   return (
   return (
-    <>
+    <LinkWrapper>
       {to ? (
       {to ? (
-        <StyledLink 
-          to={to} 
-          target={target}
-          hasunderline={hasunderline}
-        >
+        <StyledLink to={to} target={target}>
           {children}
           {children}
+          {target === "_blank" && (
+            <div>
+            <Svg data-testid="geist-icon" fill="none" height="1em" shape-rendering="geometricPrecision" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" viewBox="0 0 24 24" width="1em" data-darkreader-inline-stroke="" data-darkreader-inline-color=""><path d="M18 13v6a2 2 0 01-2 2H5a2 2 0 01-2-2V8a2 2 0 012-2h6"></path><path d="M15 3h6v6"></path><path d="M10 14L21 3"></path></Svg>
+            </div>
+          )}
         </StyledLink>
         </StyledLink>
       ) : (
       ) : (
-        <Div 
-          onClick={onClick}
-          hasunderline={hasunderline}
-        >
+        <Div onClick={onClick}>
           {children}
           {children}
         </Div>
         </Div>
       )}
       )}
-    </>
+      {hasunderline && <Underline />}
+    </LinkWrapper>
   );
   );
 };
 };
 
 
 export default Link;
 export default Link;
 
 
-const Div = styled.span<{ hasunderline?: boolean }>`
+const Svg = styled.svg`
+  margin-bottom: -1px;
+  margin-left: 5px;
+  color: #ffffff;
+  stroke: #ffffff;
+  stroke-width: 2;
+`;
+
+const Underline = styled.div`
+  position: absolute;
+  left: 0px;
+  height: 1px;
+  width: 100%;
+  background: #ffffff;
+`;
+
+const LinkWrapper = styled.span`
+  position: relative;
+`;
+
+const Div = styled.span`
   color: #ffffff;
   color: #ffffff;
   cursor: pointer;
   cursor: pointer;
   font-size: 13px;
   font-size: 13px;
   display: inline-flex;
   display: inline-flex;
-  border-bottom: ${props => props.hasunderline ? "1px solid #fff" : ""};
 `;
 `;
 
 
 const StyledLink = styled(DynamicLink)<{ hasunderline?: boolean }>`
 const StyledLink = styled(DynamicLink)<{ hasunderline?: boolean }>`
@@ -54,6 +74,4 @@ const StyledLink = styled(DynamicLink)<{ hasunderline?: boolean }>`
   display: inline-flex;
   display: inline-flex;
   font-size: 13px;
   font-size: 13px;
   cursor: pointer;
   cursor: pointer;
-  text-decoration: ;
-  border-bottom: ${props => props.hasunderline ? "1px solid #fff" : ""};
 `;
 `;

+ 9 - 4
dashboard/src/components/porter/Text.tsx

@@ -6,13 +6,15 @@ type Props = {
   color?: string;
   color?: string;
   weight?: number;
   weight?: number;
   children: any;
   children: any;
+  additionalStyles?: string;
 };
 };
 
 
 const Text: React.FC<Props> = ({
 const Text: React.FC<Props> = ({
   size,
   size,
   weight,
   weight,
   color,
   color,
-  children
+  children,
+  additionalStyles
 }) => {
 }) => {
   const getColor = () => {
   const getColor = () => {
     switch (color) {
     switch (color) {
@@ -22,12 +24,13 @@ const Text: React.FC<Props> = ({
         return color;
         return color;
     }
     }
   };
   };
-  
+
   return (
   return (
     <StyledText
     <StyledText
       size={size}
       size={size}
       color={getColor()}
       color={getColor()}
       weight={weight}
       weight={weight}
+      additionalStyles={additionalStyles}
     >
     >
       {children}
       {children}
     </StyledText>
     </StyledText>
@@ -36,10 +39,11 @@ const Text: React.FC<Props> = ({
 
 
 export default Text;
 export default Text;
 
 
-const StyledText = styled.div<{ 
-  size?: number; 
+const StyledText = styled.div<{
+  size?: number;
   color?: string;
   color?: string;
   weight?: number;
   weight?: number;
+  additionalStyles?: string;
 }>`
 }>`
   line-height: 1.5;
   line-height: 1.5;
   font-weight: ${props => props.weight || 400};
   font-weight: ${props => props.weight || 400};
@@ -47,4 +51,5 @@ const StyledText = styled.div<{
   font-size: ${props => props.size || 13}px;
   font-size: ${props => props.size || 13}px;
   display: inline;
   display: inline;
   align-items: center;
   align-items: center;
+  ${props => props.additionalStyles ? props.additionalStyles : ""}
 `;
 `;

+ 17 - 14
dashboard/src/components/repo-selector/DetectContentsList.tsx

@@ -19,6 +19,7 @@ import Modal from "components/porter/Modal";
 import Input from "components/porter/Input";
 import Input from "components/porter/Input";
 import Text from "components/porter/Text";
 import Text from "components/porter/Text";
 import { set } from "lodash";
 import { set } from "lodash";
+import Link from "../porter/Link";
 
 
 interface AutoBuildpack {
 interface AutoBuildpack {
   name?: string;
   name?: string;
@@ -188,7 +189,7 @@ const DetectContentsList: React.FC<PropsType> = (props) => {
         );
         );
         return res;
         return res;
       } catch (err) {
       } catch (err) {
-        console.log(err);
+        // console.log(err);
       }
       }
     } else if (actionConfig.kind === "gitlab") {
     } else if (actionConfig.kind === "gitlab") {
       try {
       try {
@@ -310,19 +311,17 @@ const DetectContentsList: React.FC<PropsType> = (props) => {
       <Spacer y={1} />
       <Spacer y={1} />
       <span>
       <span>
         <Text color="helper">
         <Text color="helper">
-          We were unable to find porter.yaml in your root directory. We
-          recommend that you
-        </Text>{" "}
-        <a
-          href="https://docs.porter.run/deploying-applications/application-porter-yaml-reference"
+          We were unable to find <Code>porter.yaml</Code> in your root directory. We
+          recommend that you add a <Code>porter.yaml</Code> file to your root directory
+          or specify the path here.
+        </Text>
+        <Link
+          to="https://docs.porter.run/standard/deploying-applications/writing-porter-yaml"
           target="_blank"
           target="_blank"
-          rel="noopener noreferrer"
+          hasunderline
         >
         >
-          add porter.yaml
-        </a>{" "}
-        <Text color="helper">
-          to your root directory or specify the subdirectory path here.
-        </Text>
+          Using porter.yaml
+        </Link>
       </span>
       </span>
     </div>
     </div>
   );
   );
@@ -409,6 +408,10 @@ const DetectContentsList: React.FC<PropsType> = (props) => {
 
 
 export default DetectContentsList;
 export default DetectContentsList;
 
 
+const Code = styled.span`
+  font-family: monospace;
+`;
+
 const FlexWrapper = styled.div`
 const FlexWrapper = styled.div`
   position: absolute;
   position: absolute;
   bottom: 28px;
   bottom: 28px;
@@ -588,7 +591,7 @@ const Item = styled.div`
   font-size: 13px;
   font-size: 13px;
   border-bottom: 1px solid
   border-bottom: 1px solid
     ${(props: { lastItem: boolean; isSelected?: boolean }) =>
     ${(props: { lastItem: boolean; isSelected?: boolean }) =>
-      props.lastItem ? "#00000000" : "#606166"};
+    props.lastItem ? "#00000000" : "#606166"};
   color: #ffffff;
   color: #ffffff;
   user-select: none;
   user-select: none;
   align-items: center;
   align-items: center;
@@ -619,7 +622,7 @@ const FileItem = styled(Item)`
     props.isADocker ? "#fff" : "#ffffff55"};
     props.isADocker ? "#fff" : "#ffffff55"};
   :hover {
   :hover {
     background: ${(props: { isADocker?: boolean }) =>
     background: ${(props: { isADocker?: boolean }) =>
-      props.isADocker ? "#ffffff22" : "#ffffff11"};
+    props.isADocker ? "#ffffff22" : "#ffffff11"};
   }
   }
 `;
 `;
 
 

+ 91 - 83
dashboard/src/main/home/add-on-dashboard/AddOnDashboard.tsx

@@ -34,6 +34,7 @@ import Loading from "components/Loading";
 import { Link } from "react-router-dom";
 import { Link } from "react-router-dom";
 import Fieldset from "components/porter/Fieldset";
 import Fieldset from "components/porter/Fieldset";
 import Select from "components/porter/Select";
 import Select from "components/porter/Select";
+import ClusterProvisioningPlaceholder from "components/ClusterProvisioningPlaceholder";
 
 
 type Props = {
 type Props = {
 };
 };
@@ -45,6 +46,7 @@ const namespaceBlacklist = [
   "kube-public",
   "kube-public",
   "kube-system",
   "kube-system",
   "monitoring",
   "monitoring",
+  "porter-agent-system",
 ];
 ];
 
 
 const templateBlacklist = [
 const templateBlacklist = [
@@ -148,90 +150,96 @@ const AppDashboard: React.FC<Props> = ({
         description="Add-ons and supporting workloads for this project."
         description="Add-ons and supporting workloads for this project."
         disableLineBreak
         disableLineBreak
       />
       />
-      <Container row spaced>
-        <SearchBar 
-          value={searchValue}
-          setValue={setSearchValue}
-          placeholder="Search add-ons . . ."
-          width="100%"
-        />
-        <Spacer inline x={2} />
-        <Toggle
-          items={[
-            { label: <ToggleIcon src={grid} />, value: "grid" },
-            { label: <ToggleIcon src={list} />, value: "list" },
-          ]}
-          active={view}
-          setActive={setView}
-        />
-        <Spacer inline x={2} />
-        <Link to="/addons/new">
-          <Button onClick={() => {}} height="30px" width="130px">
-            <I className="material-icons">add</I> New add-on
-          </Button>
-        </Link>
-      </Container>
-      <Spacer y={1} />
-      {(!isLoading && filteredAddOns.length === 0) && (
-        <Fieldset>
-          <Container row>
-            <PlaceholderIcon src={notFound} />
-            <Text color="helper">No add-ons were found.</Text>
-          </Container>
-        </Fieldset>
-      )}
-      {isLoading ? <Loading offset="-150px" /> : view === "grid" ? (
-        <GridList>
-          {(filteredAddOns ?? []).map((app: any, i: number) => {
-            return (
-              <Block to={getExpandedChartLinkURL(app)} key={i}>
-                <Container row>
-                  <Icon 
-                    src={
-                      hardcodedIcons[app.chart.metadata.name] ||
-                      app.chart.metadata.icon
-                    }
-                  />
-                  <Text size={14}>
-                    {app.name}
-                  </Text>
-                </Container>
-                <StatusIcon src={healthy} />
-                <Text size={13} color="#ffffff44">
-                  <SmallIcon opacity="0.4" src={time} />
-                  {readableDate(app.info.last_deployed)}
-                </Text>
-              </Block>
-            );
-          })}
-       </GridList>
+      {currentCluster?.status === "UPDATING_UNAVAILABLE" ? (
+        <ClusterProvisioningPlaceholder />
       ) : (
       ) : (
-        <List>
-          {(filteredAddOns ?? []).map((app: any, i: number) => {
-            return (
-              <Row to={getExpandedChartLinkURL(app)} key={i}>
-                <Container row>
-                  <MidIcon
-                    src={
-                      hardcodedIcons[app.chart.metadata.name] ||
-                      app.chart.metadata.icon
-                    }
-                  />
-                  <Text size={14}>
-                    {app.name}
-                  </Text>
-                  <Spacer inline x={1} />
-                  <MidIcon src={healthy} height="16px" />
-                </Container>
-                <Spacer height="15px" />
-                <Text size={13} color="#ffffff44">
-                  <SmallIcon opacity="0.4" src={time} />
-                  {readableDate(app.info.last_deployed)}
-                </Text>
-              </Row>
-            );
-          })}
-        </List>
+        <>
+          <Container row spaced>
+            <SearchBar 
+              value={searchValue}
+              setValue={setSearchValue}
+              placeholder="Search add-ons . . ."
+              width="100%"
+            />
+            <Spacer inline x={2} />
+            <Toggle
+              items={[
+                { label: <ToggleIcon src={grid} />, value: "grid" },
+                { label: <ToggleIcon src={list} />, value: "list" },
+              ]}
+              active={view}
+              setActive={setView}
+            />
+            <Spacer inline x={2} />
+            <Link to="/addons/new">
+              <Button onClick={() => {}} height="30px" width="130px">
+                <I className="material-icons">add</I> New add-on
+              </Button>
+            </Link>
+          </Container>
+          <Spacer y={1} />
+          {(!isLoading && filteredAddOns.length === 0) && (
+            <Fieldset>
+              <Container row>
+                <PlaceholderIcon src={notFound} />
+                <Text color="helper">No add-ons were found.</Text>
+              </Container>
+            </Fieldset>
+          )}
+          {isLoading ? <Loading offset="-150px" /> : view === "grid" ? (
+            <GridList>
+              {(filteredAddOns ?? []).map((app: any, i: number) => {
+                return (
+                  <Block to={getExpandedChartLinkURL(app)} key={i}>
+                    <Container row>
+                      <Icon 
+                        src={
+                          hardcodedIcons[app.chart.metadata.name] ||
+                          app.chart.metadata.icon
+                        }
+                      />
+                      <Text size={14}>{app.name}</Text>
+                    </Container>
+                    <StatusIcon src={healthy} />
+                    <Container row>
+                      <SmallIcon opacity="0.4" src={time} />
+                      <Text size={13} color="#ffffff44">
+                        {readableDate(app.info.last_deployed)}
+                      </Text>
+                    </Container>
+                  </Block>
+                );
+              })}
+          </GridList>
+          ) : (
+            <List>
+              {(filteredAddOns ?? []).map((app: any, i: number) => {
+                return (
+                  <Row to={getExpandedChartLinkURL(app)} key={i}>
+                    <Container row>
+                      <MidIcon
+                        src={
+                          hardcodedIcons[app.chart.metadata.name] ||
+                          app.chart.metadata.icon
+                        }
+                      />
+                      <Text size={14}>{app.name}</Text>
+                      <Spacer inline x={1} />
+                      <MidIcon src={healthy} height="16px" />
+                    </Container>
+                    <Spacer height="15px" />
+                    <Container row>
+                      <SmallIcon opacity="0.4" src={time} />
+                      <Text size={13} color="#ffffff44">
+                        {readableDate(app.info.last_deployed)}
+                      </Text>
+                    </Container>
+                  </Row>
+                );
+              })}
+            </List>
+          )}
+        </>
       )}
       )}
       <Spacer y={5} />
       <Spacer y={5} />
     </StyledAppDashboard>
     </StyledAppDashboard>

+ 2 - 2
dashboard/src/main/home/add-on-dashboard/ConfigureTemplate.tsx

@@ -182,7 +182,7 @@ const ConfigureTemplate: React.FC<Props> = ({
                 src={hardcodedIcons[currentTemplate.name] || currentTemplate.icon}
                 src={hardcodedIcons[currentTemplate.name] || currentTemplate.icon}
               />
               />
             }
             }
-            title={`Configure new ${hardcodedNames[currentTemplate.name] || currentTemplate.name} instance`}
+            title={`Configure new "${hardcodedNames[currentTemplate.name] || currentTemplate.name}" instance`}
             capitalize={false}
             capitalize={false}
             disableLineBreak
             disableLineBreak
           />
           />
@@ -194,7 +194,7 @@ const ConfigureTemplate: React.FC<Props> = ({
                 <Text size={16}>Add-on name</Text>
                 <Text size={16}>Add-on name</Text>
                 <Spacer y={0.5} />
                 <Spacer y={0.5} />
                 <Text color="helper">
                 <Text color="helper">
-                  Randomly generated if left blank (lowercase letters, numbers, and "-" only).
+                  Lowercase letters, numbers, and "-" only.
                 </Text>
                 </Text>
                 <Spacer height="20px" />
                 <Spacer height="20px" />
                 <Input
                 <Input

+ 111 - 108
dashboard/src/main/home/app-dashboard/AppDashboard.tsx

@@ -27,6 +27,8 @@ import Toggle from "components/porter/Toggle";
 import PorterLink from "components/porter/Link";
 import PorterLink from "components/porter/Link";
 import Loading from "components/Loading";
 import Loading from "components/Loading";
 import Fieldset from "components/porter/Fieldset";
 import Fieldset from "components/porter/Fieldset";
+import ClusterProvisioningPlaceholder from "components/ClusterProvisioningPlaceholder";
+import Icon from "components/porter/Icon";
 
 
 type Props = {};
 type Props = {};
 
 
@@ -49,7 +51,7 @@ const namespaceBlacklist = [
 ];
 ];
 
 
 const AppDashboard: React.FC<Props> = ({ }) => {
 const AppDashboard: React.FC<Props> = ({ }) => {
-  const { currentProject, currentCluster } = useContext(Context);
+  const { currentProject, currentCluster, setFeaturePreview } = useContext(Context);
   const [apps, setApps] = useState([]);
   const [apps, setApps] = useState([]);
   const [charts, setCharts] = useState([]);
   const [charts, setCharts] = useState([]);
   const [error, setError] = useState(null);
   const [error, setError] = useState(null);
@@ -130,19 +132,19 @@ const AppDashboard: React.FC<Props> = ({ }) => {
     return (
     return (
       <>
       <>
         {app.repo_name ? (
         {app.repo_name ? (
-          <>
+          <Container row>
             <SmallIcon opacity="0.6" src={github} />
             <SmallIcon opacity="0.6" src={github} />
-            {app.repo_name}
-          </>
+            <Text size={13} color="#ffffff44">{app.repo_name}</Text>
+          </Container>
         ) : (
         ) : (
-          <>
+          <Container row>
             <SmallIcon
             <SmallIcon
               opacity="0.7"
               opacity="0.7"
               height="18px"
               height="18px"
               src="https://cdn4.iconfinder.com/data/icons/logos-and-brands/512/97_Docker_logo_logos-512.png"
               src="https://cdn4.iconfinder.com/data/icons/logos-and-brands/512/97_Docker_logo_logos-512.png"
             />
             />
-            {app.image_repo_uri}
-          </>
+            <Text size={13} color="#ffffff44">{app.image_repo_uri}</Text>
+          </Container>
         )}
         )}
       </>
       </>
     );
     );
@@ -188,7 +190,7 @@ const AppDashboard: React.FC<Props> = ({ }) => {
       }
       }
     }
     }
     return (
     return (
-      <>{size === "larger" ? <MidIcon src={src} /> : <Icon src={src} />}</>
+      <>{size === "larger" ? <Icon height="16px" src={src} /> : <Icon height="18px" src={src} />}</>
     );
     );
   };
   };
 
 
@@ -200,96 +202,108 @@ const AppDashboard: React.FC<Props> = ({ }) => {
         description="Web services, workers, and jobs for this project."
         description="Web services, workers, and jobs for this project."
         disableLineBreak
         disableLineBreak
       />
       />
-      <Container row spaced>
-        <SearchBar
-          value={searchValue}
-          setValue={setSearchValue}
-          placeholder="Search applications . . ."
-          width="100%"
-        />
-        <Spacer inline x={2} />
-        <Toggle
-          items={[
-            { label: <ToggleIcon src={grid} />, value: "grid" },
-            { label: <ToggleIcon src={list} />, value: "list" },
-          ]}
-          active={view}
-          setActive={setView}
-        />
-        <Spacer inline x={2} />
-        <PorterLink to="/apps/new/app">
-          <Button onClick={async () => updateStackStartedStep()} height="30px" width="160px">
-            <I className="material-icons">add</I> New application
-          </Button>
-        </PorterLink>
-      </Container>
-      <Spacer y={1} />
-      {!isLoading && filteredApps.length === 0 && (
-        <Fieldset>
-          <Container row>
-            <PlaceholderIcon src={notFound} />
-            <Text color="helper">No applications were found.</Text>
-          </Container>
-        </Fieldset>
-      )}
-      {isLoading ? (
-        <Loading offset="-150px" />
-      ) : view === "grid" ? (
-        <GridList>
-          {(filteredApps ?? []).map((app: any, i: number) => {
-            if (!namespaceBlacklist.includes(app.name)) {
-              return (
-                <Link to={`/apps/${app.name}`} key={i}>
-                  <Block>
-                    <Container row>
-                      {renderIcon(app["build_packs"])}
-                      <Text size={14}>
-                        {app.name}
-                      </Text>
-                      <Spacer inline x={2} />
-                    </Container>
-                    <StatusIcon src={healthy} />
-                    <Text size={13} color="#ffffff44">
-                      {renderSource(app)}
-                    </Text>
-                    <Text size={13} color="#ffffff44">
-                      <SmallIcon opacity="0.4" src={time} />
-                      {app.last_deployed}
-                    </Text>
-                  </Block>
-                </Link>
-              );
-            }
-          })}
-        </GridList>
+      {currentCluster?.status === "UPDATING_UNAVAILABLE" ? (
+        <ClusterProvisioningPlaceholder />
       ) : (
       ) : (
-        <List>
-          {(filteredApps ?? []).map((app: any, i: number) => {
-            if (!namespaceBlacklist.includes(app.name)) {
-              return (
-                <Link to={`/apps/${app.name}`} key={i}>
-                  <Row>
-                    <Container row>
-                      {renderIcon(app["build_packs"], "larger")}
-                      <Text size={14}>
-                        {app.name}
-                      </Text>
-                      <Spacer inline x={1} />
-                      <MidIcon src={healthy} />
-                    </Container>
-                    <Spacer height="15px" />
-                    <Text size={13} color="#ffffff44">
-                      {renderSource(app)}
-                      <Spacer inline x={1} />
-                      <SmallIcon opacity="0.4" src={time} />
-                      {app.last_deployed}
-                    </Text>
-                  </Row>
-                </Link>
-              );
-            }
-          })}
-        </List>
+        <>
+          <Container row spaced>
+            <SearchBar
+              value={searchValue}
+              setValue={(x) => {
+                if (x === "open_sesame") {
+                  setFeaturePreview(true);
+                }
+                setSearchValue(x);
+              }}
+              placeholder="Search applications . . ."
+              width="100%"
+            />
+            <Spacer inline x={2} />
+            <Toggle
+              items={[
+                { label: <ToggleIcon src={grid} />, value: "grid" },
+                { label: <ToggleIcon src={list} />, value: "list" },
+              ]}
+              active={view}
+              setActive={setView}
+            />
+            <Spacer inline x={2} />
+            <PorterLink to="/apps/new/app">
+              <Button onClick={async () => updateStackStartedStep()} height="30px" width="160px">
+                <I className="material-icons">add</I> New application
+              </Button>
+            </PorterLink>
+          </Container>
+          <Spacer y={1} />
+          {!isLoading && filteredApps.length === 0 && (
+            <Fieldset>
+              <Container row>
+                <PlaceholderIcon src={notFound} />
+                <Text color="helper">No applications were found.</Text>
+              </Container>
+            </Fieldset>
+          )}
+          {isLoading ? (
+            <Loading offset="-150px" />
+          ) : view === "grid" ? (
+            <GridList>
+              {(filteredApps ?? []).map((app: any, i: number) => {
+                if (!namespaceBlacklist.includes(app.name)) {
+                  return (
+                    <Link to={`/apps/${app.name}`} key={i}>
+                      <Block>
+                        <Container row>
+                          {renderIcon(app["build_packs"])}
+                          <Spacer inline width="12px" />
+                          <Text size={14}>{app.name}</Text>
+                          <Spacer inline x={2} />
+                        </Container>
+                        <StatusIcon src={healthy} />
+                        {renderSource(app)}
+                        <Container row>
+                          <SmallIcon opacity="0.4" src={time} />
+                          <Text size={13} color="#ffffff44">{app.last_deployed}</Text>
+                        </Container>
+                      </Block>
+                    </Link>
+                  );
+                }
+              })}
+            </GridList>
+          ) : (
+            <List>
+              {(filteredApps ?? []).map((app: any, i: number) => {
+                if (!namespaceBlacklist.includes(app.name)) {
+                  return (
+                    <Link to={`/apps/${app.name}`} key={i}>
+                      <Row>
+                        <Container row>
+                          <Spacer inline width="1px" />
+                          {renderIcon(app["build_packs"], "larger")}
+                          <Spacer inline width="12px" />
+                          <Text size={14}>
+                            {app.name}
+                          </Text>
+                          <Spacer inline x={1} />
+                          <Icon height="16px" src={healthy} />
+                        </Container>
+                        <Spacer height="15px" />
+                        <Container row>
+                          {renderSource(app)}
+                          <Spacer inline x={1} />
+                          <SmallIcon opacity="0.4" src={time} />
+                          <Text size={13} color="#ffffff44">
+                            {app.last_deployed}
+                          </Text>
+                        </Container>
+                      </Row>
+                    </Link>
+                  );
+                }
+              })}
+            </List>
+          )}
+        </>
       )}
       )}
       <Spacer y={5} />
       <Spacer y={5} />
     </StyledAppDashboard>
     </StyledAppDashboard>
@@ -334,17 +348,6 @@ const StatusIcon = styled.img`
   height: 18px;
   height: 18px;
 `;
 `;
 
 
-const Icon = styled.img`
-  height: 18px;
-  margin-right: 15px;
-`;
-
-const MidIcon = styled.img`
-  height: 16px;
-  margin-right: 13px;
-  margin-left: 1px;
-`;
-
 const SmallIcon = styled.img<{ opacity?: string; height?: string }>`
 const SmallIcon = styled.img<{ opacity?: string; height?: string }>`
   margin-left: 2px;
   margin-left: 2px;
   height: ${(props) => props.height || "14px"};
   height: ${(props) => props.height || "14px"};

+ 431 - 184
dashboard/src/main/home/app-dashboard/expanded-app/ExpandedApp.tsx

@@ -7,13 +7,20 @@ import { z } from "zod";
 import notFound from "assets/not-found.png";
 import notFound from "assets/not-found.png";
 import web from "assets/web.png";
 import web from "assets/web.png";
 import box from "assets/box.png";
 import box from "assets/box.png";
-import github from "assets/github.png";
+import github from "assets/github-white.png";
 import pr_icon from "assets/pull_request_icon.svg";
 import pr_icon from "assets/pull_request_icon.svg";
 import loadingImg from "assets/loading.gif";
 import loadingImg from "assets/loading.gif";
 import refresh from "assets/refresh.png";
 import refresh from "assets/refresh.png";
+<<<<<<< HEAD
 import history from "assets/history.png";
 import history from "assets/history.png";
+=======
+import deploy from "assets/deploy.png";
+import save from "assets/save-01.svg";
+import danger from "assets/danger.svg";
+>>>>>>> eff3d25f3e5dc46e0feb70c1af18b6f1d91c69e5
 
 
 import api from "shared/api";
 import api from "shared/api";
+import JSZip from "jszip";
 import { Context } from "shared/Context";
 import { Context } from "shared/Context";
 import useAuth from "shared/auth/useAuth";
 import useAuth from "shared/auth/useAuth";
 import Error from "components/porter/Error";
 import Error from "components/porter/Error";
@@ -27,6 +34,7 @@ import Spacer from "components/porter/Spacer";
 import Link from "components/porter/Link";
 import Link from "components/porter/Link";
 import Back from "components/porter/Back";
 import Back from "components/porter/Back";
 import TabSelector from "components/TabSelector";
 import TabSelector from "components/TabSelector";
+import Icon from "components/porter/Icon";
 import { ChartType, PorterAppOptions, ResourceType } from "shared/types";
 import { ChartType, PorterAppOptions, ResourceType } from "shared/types";
 import RevisionSection from "main/home/cluster-dashboard/expanded-chart/RevisionSection";
 import RevisionSection from "main/home/cluster-dashboard/expanded-chart/RevisionSection";
 import BuildSettingsTabStack from "./BuildSettingsTabStack";
 import BuildSettingsTabStack from "./BuildSettingsTabStack";
@@ -43,13 +51,21 @@ import { PorterYamlSchema } from "../new-app-flow/schema";
 import { EnvVariablesTab } from "./EnvVariablesTab";
 import { EnvVariablesTab } from "./EnvVariablesTab";
 import GHABanner from "./GHABanner";
 import GHABanner from "./GHABanner";
 import LogSection from "./LogSection";
 import LogSection from "./LogSection";
-import EventsTab from "./EventsTab";
 import ActivityFeed from "./activity-feed/ActivityFeed";
 import ActivityFeed from "./activity-feed/ActivityFeed";
 import JobRuns from "./JobRuns";
 import JobRuns from "./JobRuns";
 import MetricsSection from "./MetricsSection";
 import MetricsSection from "./MetricsSection";
 import StatusSectionFC from "./status/StatusSection";
 import StatusSectionFC from "./status/StatusSection";
 import ExpandedJob from "./expanded-job/ExpandedJob";
 import ExpandedJob from "./expanded-job/ExpandedJob";
+<<<<<<< HEAD
 import Modal from "components/porter/Modal";
 import Modal from "components/porter/Modal";
+=======
+import { Log } from "main/home/cluster-dashboard/expanded-chart/logs-section/useAgentLogs";
+import Anser, { AnserJsonEntry } from "anser";
+import GHALogsModal from "./status/GHALogsModal";
+import _ from "lodash";
+import AnimateHeight from "react-animate-height";
+import EventsTab from "./EventsTab";
+>>>>>>> eff3d25f3e5dc46e0feb70c1af18b6f1d91c69e5
 
 
 type Props = RouteComponentProps & {};
 type Props = RouteComponentProps & {};
 
 
@@ -62,9 +78,12 @@ const icons = [
 ];
 ];
 
 
 const ExpandedApp: React.FC<Props> = ({ ...props }) => {
 const ExpandedApp: React.FC<Props> = ({ ...props }) => {
-  const { currentCluster, currentProject, setCurrentError } = useContext(
-    Context
-  );
+  const {
+    currentCluster,
+    currentProject,
+    setCurrentError,
+    featurePreview,
+  } = useContext(Context);
   const [isLoading, setIsLoading] = useState(true);
   const [isLoading, setIsLoading] = useState(true);
   const [deleting, setDeleting] = useState(false);
   const [deleting, setDeleting] = useState(false);
   const [appData, setAppData] = useState(null);
   const [appData, setAppData] = useState(null);
@@ -77,22 +96,30 @@ const ExpandedApp: React.FC<Props> = ({ ...props }) => {
   const [forceRefreshRevisions, setForceRefreshRevisions] = useState<boolean>(
   const [forceRefreshRevisions, setForceRefreshRevisions] = useState<boolean>(
     false
     false
   );
   );
-  const [isLoadingChartData, setIsLoadingChartData] = useState<boolean>(true);
 
 
   const [tab, setTab] = useState("activity");
   const [tab, setTab] = useState("activity");
+<<<<<<< HEAD
   const [saveValuesStatus, setSaveValueStatus] = useState<string>(null);
   const [saveValuesStatus, setSaveValueStatus] = useState<string>(null);
+=======
+  const [saveValuesStatus, setSaveValueStatus] = useState<string>("");
+>>>>>>> eff3d25f3e5dc46e0feb70c1af18b6f1d91c69e5
   const [loading, setLoading] = useState<boolean>(false);
   const [loading, setLoading] = useState<boolean>(false);
-  const [components, setComponents] = useState<ResourceType[]>([]);
+  const [bannerLoading, setBannerLoading] = useState<boolean>(false);
 
 
   const [showRevisions, setShowRevisions] = useState<boolean>(false);
   const [showRevisions, setShowRevisions] = useState<boolean>(false);
   const [showDeleteOverlay, setShowDeleteOverlay] = useState<boolean>(false);
   const [showDeleteOverlay, setShowDeleteOverlay] = useState<boolean>(false);
-  const [porterJson, setPorterJson] = useState<
-    z.infer<typeof PorterYamlSchema> | undefined
-  >(undefined);
+
+  // this is what we read from their porter.yaml in github
+  const [porterJson, setPorterJson] = useState<PorterJson | undefined>(undefined);
+  // this is what we use to update the release. the above is a subset of this
+  const [porterYaml, setPorterYaml] = useState<PorterJson>({} as PorterJson);
+  const [showUnsavedChangesBanner, setShowUnsavedChangesBanner] = useState<boolean>(false);
+
   const [expandedJob, setExpandedJob] = useState(null);
   const [expandedJob, setExpandedJob] = useState(null);
+  const [logs, setLogs] = useState<Log[]>([]);
+  const [modalVisible, setModalVisible] = useState(false);
 
 
   const [services, setServices] = useState<Service[]>([]);
   const [services, setServices] = useState<Service[]>([]);
-  const [releaseJob, setReleaseJob] = useState<ReleaseService[]>([]);
   const [envVars, setEnvVars] = useState<KeyValueType[]>([]);
   const [envVars, setEnvVars] = useState<KeyValueType[]>([]);
   const [buttonStatus, setButtonStatus] = useState<React.ReactNode>("");
   const [buttonStatus, setButtonStatus] = useState<React.ReactNode>("");
   const [subdomain, setSubdomain] = useState<string>("");
   const [subdomain, setSubdomain] = useState<string>("");
@@ -100,8 +127,9 @@ const ExpandedApp: React.FC<Props> = ({ ...props }) => {
 
 
   const [showRevisionModal, setShowRevisionModal] = useState<boolean>(false);
   const [showRevisionModal, setShowRevisionModal] = useState<boolean>(false);
 
 
+
   const getPorterApp = async () => {
   const getPorterApp = async () => {
-    // setIsLoading(true);
+    setBannerLoading(true);
     const { appName } = props.match.params as any;
     const { appName } = props.match.params as any;
     try {
     try {
       if (!currentCluster || !currentProject) {
       if (!currentCluster || !currentProject) {
@@ -160,10 +188,23 @@ const ExpandedApp: React.FC<Props> = ({ ...props }) => {
 
 
       setPorterJson(porterJson);
       setPorterJson(porterJson);
       setAppData(newAppData);
       setAppData(newAppData);
-      updateServicesAndEnvVariables(resChartData?.data, releaseChartData?.data, porterJson);
+      const [newServices, newEnvVars] = updateServicesAndEnvVariables(
+        resChartData?.data,
+        releaseChartData?.data,
+        porterJson,
+      );
+      const finalPorterYaml = createFinalPorterYaml(
+        newServices,
+        newEnvVars,
+        porterJson,
+        // if we are using a heroku buildpack, inject a PORT env variable
+        newAppData.app.builder != null && newAppData.app.builder.includes("heroku")
+      );
+      setPorterYaml(finalPorterYaml);
 
 
       // Only check GHA status if no built image is set
       // Only check GHA status if no built image is set
-      const hasBuiltImage = !!resChartData.data.config?.global?.image?.repository;
+      const hasBuiltImage = !!resChartData.data.config?.global?.image
+        ?.repository;
       if (hasBuiltImage || !resPorterApp.data.repo_name) {
       if (hasBuiltImage || !resPorterApp.data.repo_name) {
         setWorkflowCheckPassed(true);
         setWorkflowCheckPassed(true);
         setHasBuiltImage(true);
         setHasBuiltImage(true);
@@ -258,7 +299,6 @@ const ExpandedApp: React.FC<Props> = ({ ...props }) => {
       ) {
       ) {
         const finalPorterYaml = createFinalPorterYaml(
         const finalPorterYaml = createFinalPorterYaml(
           services,
           services,
-          releaseJob,
           envVars,
           envVars,
           porterJson,
           porterJson,
           // if we are using a heroku buildpack, inject a PORT env variable
           // if we are using a heroku buildpack, inject a PORT env variable
@@ -279,7 +319,9 @@ const ExpandedApp: React.FC<Props> = ({ ...props }) => {
             stack_name: appData.app.name,
             stack_name: appData.app.name,
           }
           }
         );
         );
+        setPorterYaml(finalPorterYaml);
         setButtonStatus("success");
         setButtonStatus("success");
+        setShowUnsavedChangesBanner(false);
       } else {
       } else {
         setButtonStatus(<Error message="Unable to update app" />);
         setButtonStatus(<Error message="Unable to update app" />);
       }
       }
@@ -294,6 +336,77 @@ const ExpandedApp: React.FC<Props> = ({ ...props }) => {
     }
     }
   };
   };
 
 
+  useEffect(() => {
+    setBannerLoading(true);
+    getBuildLogs().then(() => {
+      setBannerLoading(false);
+    });
+  }, [appData]);
+
+  const getBuildLogs = async () => {
+    try {
+      const res = await api.getGHWorkflowLogs(
+        "",
+        {},
+        {
+          project_id: appData.app.project_id,
+          cluster_id: appData.app.cluster_id,
+          git_installation_id: appData.app.git_repo_id,
+          owner: appData.app.repo_name?.split("/")[0],
+          name: appData.app.repo_name?.split("/")[1],
+          filename: "porter_stack_" + appData.chart.name + ".yml",
+        }
+      );
+      let logs: Log[] = [];
+      if (res.data != null) {
+        // Fetch the logs
+        const logsResponse = await fetch(res.data);
+
+        // Ensure that the response body is only read once
+        const logsBlob = await logsResponse.blob();
+
+        if (logsResponse.headers.get("Content-Type") === "application/zip") {
+          const zip = await JSZip.loadAsync(logsBlob);
+
+          zip.forEach(async function (relativePath, zipEntry) {
+            const fileData = await zip.file(relativePath)?.async("string");
+
+            if (
+              fileData &&
+              fileData.includes("Run porter-dev/porter-cli-action@v0.1.0")
+            ) {
+              const lines = fileData.split("\n");
+              const timestampPattern = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}.\d+Z/;
+
+              lines.forEach((line, index) => {
+                const lineWithoutTimestamp = line
+                  .replace(timestampPattern, "")
+                  .trimStart();
+                const anserLine: AnserJsonEntry[] = Anser.ansiToJson(
+                  lineWithoutTimestamp
+                );
+                if (lineWithoutTimestamp.toLowerCase().includes("error")) {
+                  anserLine[0].fg = "238,75,43";
+                }
+
+                const log: Log = {
+                  line: anserLine,
+                  lineNumber: index + 1,
+                  timestamp: line.match(timestampPattern)?.[0],
+                };
+
+                logs.push(log);
+              });
+            }
+          });
+          setLogs(logs);
+        }
+      }
+    } catch (error) {
+      console.log(error);
+    }
+  };
+
   const fetchPorterYamlContent = async (
   const fetchPorterYamlContent = async (
     porterYaml: string,
     porterYaml: string,
     appData: any
     appData: any
@@ -346,29 +459,31 @@ const ExpandedApp: React.FC<Props> = ({ ...props }) => {
           break;
           break;
       }
       }
     }
     }
-    return <Icon src={src} />;
+    return <Icon src={src} height={"24px"} />;
   };
   };
 
 
-  const updateServicesAndEnvVariables = async (
+  const updateServicesAndEnvVariables = (
     currentChart?: ChartType,
     currentChart?: ChartType,
     releaseChart?: ChartType,
     releaseChart?: ChartType,
-    porterJson?: PorterJson
-  ) => {
+    porterJson?: PorterJson,
+  ): [Service[], KeyValueType[]] => {
     // handle normal chart
     // handle normal chart
     const helmValues = currentChart?.config;
     const helmValues = currentChart?.config;
     const defaultValues = (currentChart?.chart as any)?.values;
     const defaultValues = (currentChart?.chart as any)?.values;
+    let newServices: Service[] = [];
+    let envVars: KeyValueType[] = [];
+
     if (
     if (
       (defaultValues && Object.keys(defaultValues).length > 0) ||
       (defaultValues && Object.keys(defaultValues).length > 0) ||
       (helmValues && Object.keys(helmValues).length > 0)
       (helmValues && Object.keys(helmValues).length > 0)
     ) {
     ) {
-      const svcs = Service.deserialize(helmValues, defaultValues, porterJson);
-      setServices(svcs);
+      newServices = Service.deserialize(helmValues, defaultValues, porterJson);
       const { global, ...helmValuesWithoutGlobal } = helmValues;
       const { global, ...helmValuesWithoutGlobal } = helmValues;
       if (Object.keys(helmValuesWithoutGlobal).length > 0) {
       if (Object.keys(helmValuesWithoutGlobal).length > 0) {
-        const envs = Service.retrieveEnvFromHelmValues(helmValuesWithoutGlobal);
-        setEnvVars(envs);
+        envVars = Service.retrieveEnvFromHelmValues(helmValuesWithoutGlobal);
+        setEnvVars(envVars);
         const subdomain = Service.retrieveSubdomainFromHelmValues(
         const subdomain = Service.retrieveSubdomainFromHelmValues(
-          svcs,
+          newServices,
           helmValuesWithoutGlobal
           helmValuesWithoutGlobal
         );
         );
         setSubdomain(subdomain);
         setSubdomain(subdomain);
@@ -377,96 +492,87 @@ const ExpandedApp: React.FC<Props> = ({ ...props }) => {
 
 
     // handle release chart
     // handle release chart
     if (releaseChart?.config || porterJson?.release) {
     if (releaseChart?.config || porterJson?.release) {
-      setReleaseJob([Service.deserializeRelease(releaseChart?.config, porterJson)]);
+      const release = Service.deserializeRelease(releaseChart?.config, porterJson);
+      newServices.push(release);
     }
     }
+
+    setServices(newServices);
+
+    return [newServices, envVars];
   };
   };
 
 
-  // todo: keep a history of the release job chart, difficult because they can be upgraded asynchronously
-  const updateComponents = async (currentChart: ChartType) => {
-    setLoading(true);
+  const getChartData = async (chart: ChartType, isCurrent?: boolean) => {
+    setButtonStatus("");
     try {
     try {
-      const res = await api.getChartComponents(
+      const res = await api.getChart(
         "<token>",
         "<token>",
         {},
         {},
         {
         {
-          id: currentProject.id,
-          name: currentChart.name,
-          namespace: currentChart.namespace,
+          name: chart.name,
+          namespace: chart.namespace,
           cluster_id: currentCluster.id,
           cluster_id: currentCluster.id,
-          revision: currentChart.version,
+          revision: chart.version,
+          id: currentProject.id,
         }
         }
       );
       );
-      setComponents(res.data.Objects);
-      updateServicesAndEnvVariables(currentChart, undefined, porterJson);
-      setLoading(false);
-    } catch (error) {
-      console.log(error);
-      setLoading(false);
-    }
-  };
 
 
-  const getChartData = async (chart: ChartType) => {
-    setIsLoadingChartData(true);
-    const res = await api.getChart(
-      "<token>",
-      {},
-      {
-        name: chart.name,
-        namespace: chart.namespace,
-        cluster_id: currentCluster.id,
-        revision: chart.version,
-        id: currentProject.id,
+      const updatedChart = res.data;
+
+      if (appData != null && updatedChart != null) {
+        setAppData({ ...appData, chart: updatedChart });
       }
       }
-    );
 
 
-    const updatedChart = res.data;
+      // let releaseChartData;
+      // // get the release chart
+      // try {
+      //   releaseChartData = await api.getChart(
+      //     "<token>",
+      //     {},
+      //     {
+      //       id: currentProject.id,
+      //       namespace: `porter-stack-${chart.name}`,
+      //       cluster_id: currentCluster.id,
+      //       name: `${chart.name}-r`,
+      //       revision: 0,
+      //     }
+      //   );
+      // } catch (err) {
+      //   // do nothing, unable to find release chart
+      //   // console.log(err);
+      // }
 
 
-    if (appData != null && updatedChart != null) {
-      setAppData({ ...appData, chart: updatedChart });
-    }
+      // const releaseChart = releaseChartData?.data;
 
 
-    // let releaseChartData;
-    // // get the release chart
-    // try {
-    //   releaseChartData = await api.getChart(
-    //     "<token>",
-    //     {},
-    //     {
-    //       id: currentProject.id,
-    //       namespace: `porter-stack-${chart.name}`,
-    //       cluster_id: currentCluster.id,
-    //       name: `${chart.name}-r`,
-    //       revision: 0,
-    //     }
-    //   );
-    // } catch (err) {
-    //   // do nothing, unable to find release chart
-    //   console.log(err);
-    // }
-
-    // const releaseChart = releaseChartData?.data;
-
-    // if (appData != null && updatedChart != null) {
-    //   if (releaseChart != null) {
-    //     setAppData({ ...appData, chart: updatedChart, releaseChart });
-    //   } else {
-    //     setAppData({ ...appData, chart: updatedChart });
-    //   }
-    // }
-
-    updateComponents(updatedChart).finally(() => setIsLoadingChartData(false));
-  };
+      // if (appData != null && updatedChart != null) {
+      //   if (releaseChart != null) {
+      //     setAppData({ ...appData, chart: updatedChart, releaseChart });
+      //   } else {
+      //     setAppData({ ...appData, chart: updatedChart });
+      //   }
+      // }
 
 
-  const setRevision = (chart: ChartType, isCurrent?: boolean) => {
-    // // if we've set the revision, we also override the revision in log data
-    // let newLogData = logData;
+      const [newServices, newEnvVars] = updateServicesAndEnvVariables(
+        updatedChart,
+        appData.releaseChart,
+        porterJson,
+        appData.app.builder != null && appData.app.builder.includes("heroku")
+      );
 
 
-    // newLogData.revision = `${chart.version}`;
+      if (isCurrent) {
+        setShowUnsavedChangesBanner(false);
+      } else {
+        onAppUpdate(newServices, newEnvVars);
+      }
+    } catch (err) {
+      console.log(err);
+    } finally {
+      setLoading(false);
+    }
 
 
-    // setLogData(newLogData);
+  };
 
 
-    // setIsPreview(!isCurrent);
-    getChartData(chart);
+  const setRevision = (chart: ChartType, isCurrent?: boolean) => {
+    getChartData(chart, isCurrent);
   };
   };
 
 
   const appUpgradeVersion = useCallback(
   const appUpgradeVersion = useCallback(
@@ -541,11 +647,56 @@ const ExpandedApp: React.FC<Props> = ({ ...props }) => {
     });
     });
     return `${time} on ${date}`;
     return `${time} on ${date}`;
   };
   };
+
+  const onAppUpdate = (services: Service[], envVars: KeyValueType[]) => {
+    const newPorterYaml = createFinalPorterYaml(
+      services,
+      envVars,
+      porterJson,
+      // if we are using a heroku buildpack, inject a PORT env variable
+      appData.app.builder != null && appData.app.builder.includes("heroku")
+    );
+    if (!_.isEqual(porterYaml, newPorterYaml)) {
+      setShowUnsavedChangesBanner(true);
+    } else {
+      setShowUnsavedChangesBanner(false);
+    }
+    // console.log("old porter yaml", porterYaml);
+    // console.log("new porter yaml", newPorterYaml);
+  };
+
   const renderTabContents = () => {
   const renderTabContents = () => {
     switch (tab) {
     switch (tab) {
       case "overview":
       case "overview":
         return (
         return (
           <>
           <>
+            {/* pre-deploy stuff - only if this is from github! */}
+            {!isLoading && appData?.app?.git_repo_id != null && (
+              <>
+                <Text size={16}>Pre-deploy job</Text>
+                <Spacer y={0.5} />
+                <Services
+                  setServices={(release: Service[]) => {
+                    if (buttonStatus !== "") {
+                      setButtonStatus("");
+                    }
+                    const nonRelease = services.filter(Service.isNonRelease)
+                    const newServices = [...nonRelease, ...release]
+                    setServices(newServices)
+                    onAppUpdate(newServices, envVars)
+                  }}
+                  chart={appData.releaseChart}
+                  services={services.filter(Service.isRelease)}
+                  limitOne={true}
+                  prePopulateService={Service.default("pre-deploy", "release", porterJson)}
+                  addNewText={"Add a new pre-deploy job"}
+                  defaultExpanded={false}
+                />
+                <Spacer y={0.5} />
+              </>
+            )}
+            <Text size={16}>Application services</Text>
+            <Spacer y={0.5} />
             {!isLoading && services.length === 0 && (
             {!isLoading && services.length === 0 && (
               <>
               <>
                 <Fieldset>
                 <Fieldset>
@@ -558,18 +709,21 @@ const ExpandedApp: React.FC<Props> = ({ ...props }) => {
               </>
               </>
             )}
             )}
             <Services
             <Services
-              setServices={(x) => {
+              setServices={(svcs: Service[]) => {
                 if (buttonStatus !== "") {
                 if (buttonStatus !== "") {
                   setButtonStatus("");
                   setButtonStatus("");
                 }
                 }
-                setServices(x);
+                const release = services.filter(Service.isRelease)
+                const newServices = [...svcs, ...release]
+                setServices(newServices);
+                onAppUpdate(newServices, envVars);
               }}
               }}
+              services={services.filter(Service.isNonRelease)}
               chart={appData.chart}
               chart={appData.chart}
-              services={services}
               addNewText={"Add a new service"}
               addNewText={"Add a new service"}
               setExpandedJob={(x: string) => setExpandedJob(x)}
               setExpandedJob={(x: string) => setExpandedJob(x)}
             />
             />
-            <Spacer y={1} />
+            <Spacer y={0.75} />
             <Button
             <Button
               onClick={async () => await updatePorterApp({})}
               onClick={async () => await updatePorterApp({})}
               status={buttonStatus}
               status={buttonStatus}
@@ -612,9 +766,15 @@ const ExpandedApp: React.FC<Props> = ({ ...props }) => {
       case "events":
       case "events":
         return <EventsTab currentChart={appData.chart} />;
         return <EventsTab currentChart={appData.chart} />;
       case "activity":
       case "activity":
-        return <ActivityFeed chart={appData.chart} stackName={appData?.app?.name}/>;
+        return (
+          <ActivityFeed
+            chart={appData.chart}
+            stackName={appData?.app?.name}
+            appData={appData}
+          />
+        );
       case "logs":
       case "logs":
-        return <LogSection currentChart={appData.chart} />;
+        return <LogSection currentChart={appData.chart} services={services} />;
       case "metrics":
       case "metrics":
         return <MetricsSection currentChart={appData.chart} />;
         return <MetricsSection currentChart={appData.chart} />;
       case "status":
       case "status":
@@ -623,7 +783,10 @@ const ExpandedApp: React.FC<Props> = ({ ...props }) => {
         return (
         return (
           <EnvVariablesTab
           <EnvVariablesTab
             envVars={envVars}
             envVars={envVars}
-            setEnvVars={setEnvVars}
+            setEnvVars={(envVars: KeyValueType[]) => {
+              setEnvVars(envVars);
+              onAppUpdate(services, envVars.filter((e) => e.key !== "" || e.value !== ""));
+            }}
             status={buttonStatus}
             status={buttonStatus}
             updatePorterApp={updatePorterApp}
             updatePorterApp={updatePorterApp}
             clearStatus={() => setButtonStatus("")}
             clearStatus={() => setButtonStatus("")}
@@ -632,53 +795,30 @@ const ExpandedApp: React.FC<Props> = ({ ...props }) => {
       case "pre-deploy":
       case "pre-deploy":
         return (
         return (
           <>
           <>
-            {!isLoading && releaseJob.length === 0 && (
+            {!isLoading && !services.some(Service.isRelease) && (
               <>
               <>
                 <Fieldset>
                 <Fieldset>
                   <Container row>
                   <Container row>
                     <PlaceholderIcon src={notFound} />
                     <PlaceholderIcon src={notFound} />
-                    <Text color="helper">No pre-deploy jobs were found. Add a pre-deploy job to perform an operation before your application services deploy, like a database migration.</Text>
+                    <Text color="helper">
+                      No pre-deploy jobs were found. You can add a pre-deploy
+                      job in the Overview tab to perform an operation before
+                      your application services deploy, like a database
+                      migration.
+                    </Text>
                   </Container>
                   </Container>
                 </Fieldset>
                 </Fieldset>
                 <Spacer y={0.5} />
                 <Spacer y={0.5} />
               </>
               </>
             )}
             )}
-            <Services
-              setServices={(x) => {
-                if (buttonStatus !== "") {
-                  setButtonStatus("");
-                }
-                setReleaseJob(x as ReleaseService[]);
-              }}
-              chart={appData.releaseChart}
-              services={releaseJob}
-              limitOne={true}
-              customOnClick={() => {
-                setReleaseJob([Service.default(
-                  "pre-deploy",
-                  "release",
-                  porterJson
-                ) as ReleaseService]);
-              }}
-              addNewText={"Add a new pre-deploy job"}
-              defaultExpanded={true}
-            />
-            <Button
-              onClick={async () => await updatePorterApp({})}
-              status={buttonStatus}
-              loadingText={"Updating..."}
-              disabled={releaseJob.length === 0}
-            >
-              Update pre-deploy job
-            </Button>
-            <Spacer y={0.5} />
-            {releaseJob.length > 0 && <JobRuns
-              lastRunStatus="all"
-              namespace={appData.chart?.namespace}
-              sortType="Newest"
-              releaseName={appData.app.name + "-r"}
-            />
-            }
+            {services.some(Service.isRelease) && (
+              <JobRuns
+                lastRunStatus="all"
+                namespace={appData.chart?.namespace}
+                sortType="Newest"
+                releaseName={appData.app.name + "-r"}
+              />
+            )}
           </>
           </>
         );
         );
       default:
       default:
@@ -688,12 +828,12 @@ const ExpandedApp: React.FC<Props> = ({ ...props }) => {
 
 
   if (expandedJob) {
   if (expandedJob) {
     return (
     return (
-      <ExpandedJob 
+      <ExpandedJob
         appName={appData.app.name}
         appName={appData.app.name}
         jobName={expandedJob}
         jobName={expandedJob}
         goBack={() => setExpandedJob(null)}
         goBack={() => setExpandedJob(null)}
       />
       />
-    )
+    );
   }
   }
 
 
   return (
   return (
@@ -717,20 +857,19 @@ const ExpandedApp: React.FC<Props> = ({ ...props }) => {
           <Back to="/apps" />
           <Back to="/apps" />
           <Container row>
           <Container row>
             {renderIcon(appData.app?.build_packs)}
             {renderIcon(appData.app?.build_packs)}
+            <Spacer inline x={1} />
             <Text size={21}>{appData.app.name}</Text>
             <Text size={21}>{appData.app.name}</Text>
             {appData.app.repo_name && (
             {appData.app.repo_name && (
               <>
               <>
                 <Spacer inline x={1} />
                 <Spacer inline x={1} />
                 <Container row>
                 <Container row>
-                  <SmallIcon src={github} />
-                  <Text size={13} color="helper">
-                    <Link
-                      target="_blank"
-                      to={`https://github.com/${appData.app.repo_name}`}
-                    >
-                      {appData.app.repo_name}
-                    </Link>
-                  </Text>
+                  <A
+                    target="_blank"
+                    href={`https://github.com/${appData.app.repo_name}`}
+                  >
+                    <SmallIcon src={github} />
+                    <Text size={13}>{appData.app.repo_name}</Text>
+                  </A>
                 </Container>
                 </Container>
               </>
               </>
             )}
             )}
@@ -810,6 +949,7 @@ const ExpandedApp: React.FC<Props> = ({ ...props }) => {
           ) : (
           ) : (
             <>
             <>
               {!workflowCheckPassed ? (
               {!workflowCheckPassed ? (
+<<<<<<< HEAD
                 <>
                 <>
                   <GHABanner
                   <GHABanner
                     repoName={appData.app.repo_name}
                     repoName={appData.app.repo_name}
@@ -818,6 +958,70 @@ const ExpandedApp: React.FC<Props> = ({ ...props }) => {
                     stackName={appData.app.name}
                     stackName={appData.app.name}
                     gitRepoId={appData.app.git_repo_id}
                     gitRepoId={appData.app.git_repo_id}
                     porterYamlPath={appData.app.porter_yaml_path}
                     porterYamlPath={appData.app.porter_yaml_path}
+=======
+                bannerLoading ? (
+                  <Banner>
+                    <Loading />
+                  </Banner>
+                ) : (
+                  <GHABanner
+                    repoName={appData.app.repo_name}
+                    branchName={appData.app.git_branch}
+                    pullRequestUrl={appData.app.pull_request_url}
+                    stackName={appData.app.name}
+                    gitRepoId={appData.app.git_repo_id}
+                    porterYamlPath={appData.app.porter_yaml_path}
+                  />
+                )
+              ) : !hasBuiltImage ? (
+                bannerLoading ? (
+                  <Banner>
+                    <Loading />
+                  </Banner>
+                ) : (
+                  <Banner
+                    suffix={
+                      <>
+                        <RefreshButton
+                          onClick={() => window.location.reload()}
+                        >
+                          <img src={refresh} />
+                          Refresh
+                        </RefreshButton>
+                      </>
+                    }
+                  >
+                    Your GitHub repo has not been built yet.
+                    <Spacer inline width="5px" />
+                    <Link
+                      hasunderline
+                      target="_blank"
+                      to={`https://github.com/${appData.app.repo_name}/actions`}
+                    >
+                      Check status
+                    </Link>
+                  </Banner>
+                )
+              ) : (
+                <>
+                  <DarkMatter />
+                  <RevisionSection
+                    showRevisions={showRevisions}
+                    toggleShowRevisions={() => {
+                      setShowRevisions(!showRevisions);
+                    }}
+                    chart={appData.chart}
+                    setRevision={setRevision}
+                    forceRefreshRevisions={forceRefreshRevisions}
+                    refreshRevisionsOff={() => setForceRefreshRevisions(false)}
+                    shouldUpdate={
+                      appData.chart.latest_version &&
+                      appData.chart.latest_version !==
+                      appData.chart.chart.metadata.version
+                    }
+                    latestVersion={appData.chart.latest_version}
+                    upgradeVersion={appUpgradeVersion}
+>>>>>>> eff3d25f3e5dc46e0feb70c1af18b6f1d91c69e5
                   />
                   />
                   <Spacer y={1} />
                   <Spacer y={1} />
                 </>
                 </>
@@ -843,6 +1047,7 @@ const ExpandedApp: React.FC<Props> = ({ ...props }) => {
                   <Spacer y={1} />
                   <Spacer y={1} />
                 </>
                 </>
               )}
               )}
+<<<<<<< HEAD
               <TabSelector
               <TabSelector
                 options={
                 options={
                   appData.app.git_repo_id
                   appData.app.git_repo_id
@@ -888,6 +1093,49 @@ const ExpandedApp: React.FC<Props> = ({ ...props }) => {
                       { label: "Settings", value: "settings" },
                       { label: "Settings", value: "settings" },
                     ]
                     ]
                 }
                 }
+=======
+              <Spacer y={1} />
+              <AnimateHeight height={showUnsavedChangesBanner ? 67 : 0}>
+                <Banner
+                  type="warning"
+                  suffix={
+                    <>
+                      <Button
+                        onClick={async () => await updatePorterApp({})}
+                        status={buttonStatus}
+                        loadingText={"Updating..."}
+                        height={"10px"}
+                      >
+                        <Icon src={save} height={"13px"} />
+                        <Spacer inline x={0.5} />
+                        Save as latest version
+                      </Button>
+                    </>
+                  }
+                >
+                  Changes you are currently previewing have not been saved.
+                  <Spacer inline width="5px" />
+                </Banner>
+              </AnimateHeight>
+              <TabSelector
+                noBuffer
+                options={[
+                  { label: "Activity", value: "activity" },
+                  { label: "Overview", value: "overview" },
+                  hasBuiltImage && { label: "Logs", value: "logs" },
+                  hasBuiltImage && { label: "Metrics", value: "metrics" },
+                  hasBuiltImage && { label: "Debug", value: "status" },
+                  {
+                    label: "Environment",
+                    value: "environment-variables",
+                  },
+                  appData.app.git_repo_id && {
+                    label: "Build settings",
+                    value: "build-settings",
+                  },
+                  { label: "Settings", value: "settings" },
+                ].filter((x) => x)}
+>>>>>>> eff3d25f3e5dc46e0feb70c1af18b6f1d91c69e5
                 currentTab={tab}
                 currentTab={tab}
                 setCurrentTab={(tab: string) => {
                 setCurrentTab={(tab: string) => {
                   if (buttonStatus !== "") {
                   if (buttonStatus !== "") {
@@ -950,8 +1198,17 @@ const ExpandedApp: React.FC<Props> = ({ ...props }) => {
 
 
 export default withRouter(ExpandedApp);
 export default withRouter(ExpandedApp);
 
 
+const A = styled.a`
+  display: flex;
+  align-items: center;
+`;
+
+const Underline = styled.div`
+  border-bottom: 1px solid #ffffff;
+`;
+
 const RefreshButton = styled.div`
 const RefreshButton = styled.div`
-  color: #ffffff44;
+  color: #ffffff;
   display: flex;
   display: flex;
   align-items: center;
   align-items: center;
   cursor: pointer;
   cursor: pointer;
@@ -968,7 +1225,28 @@ const RefreshButton = styled.div`
     justify-content: center;
     justify-content: center;
     height: 11px;
     height: 11px;
     margin-right: 10px;
     margin-right: 10px;
-    opacity: 0.3;
+  }
+`;
+
+const LogsButton = styled.div`
+  color: white;
+  display: flex;
+  align-items: center;
+  cursor: pointer;
+  :hover {
+    color: red;
+    > img {
+      opacity: 1;
+    }
+  }
+
+  > img {
+    display: flex;
+    align-items: center;
+    justify-content: center;
+    height: 5px;
+    margin-right: 10px;
+    opacity: 0.8;
   }
   }
 `;
 `;
 
 
@@ -1015,11 +1293,6 @@ const BranchTag = styled.div`
   text-overflow: ellipsis;
   text-overflow: ellipsis;
 `;
 `;
 
 
-const BranchSection = styled.div`
-  background: ${(props) => props.theme.fg};
-  border: 1px solid #494b4f;
-`;
-
 const SmallIcon = styled.img<{ opacity?: string; height?: string }>`
 const SmallIcon = styled.img<{ opacity?: string; height?: string }>`
   height: ${(props) => props.height || "15px"};
   height: ${(props) => props.height || "15px"};
   opacity: ${(props) => props.opacity || 1};
   opacity: ${(props) => props.opacity || 1};
@@ -1032,11 +1305,6 @@ const BranchIcon = styled.img`
   margin-right: 5px;
   margin-right: 5px;
 `;
 `;
 
 
-const Icon = styled.img`
-  height: 24px;
-  margin-right: 15px;
-`;
-
 const PlaceholderIcon = styled.img`
 const PlaceholderIcon = styled.img`
   height: 13px;
   height: 13px;
   margin-right: 12px;
   margin-right: 12px;
@@ -1067,24 +1335,3 @@ const StyledExpandedApp = styled.div`
     }
     }
   }
   }
 `;
 `;
-
-const HeaderWrapper = styled.div`
-  position: relative;
-`;
-const LastDeployed = styled.div`
-  font-size: 13px;
-  margin-left: 8px;
-  margin-top: -1px;
-  display: flex;
-  align-items: center;
-  color: #aaaabb66;
-`;
-const Dot = styled.div`
-  margin-right: 16px;
-`;
-const InfoWrapper = styled.div`
-  display: flex;
-  align-items: center;
-  margin-left: 3px;
-  margin-top: 22px;
-`;

Некоторые файлы не были показаны из-за большого количества измененных файлов