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":
   push:
-    branches:
-      - master
-name: Deploy to Porter
+    tags:
+      - production
+name: Deploy Install Script to Production
 jobs:
   porter-deploy:
     runs-on: ubuntu-latest
@@ -17,9 +17,9 @@ jobs:
         uses: porter-dev/porter-update-action@v0.1.0
         with:
           app: install-script
-          cluster: "8"
+          cluster: "9"
           host: https://dashboard.internal-tools.getporter.dev
           namespace: default
-          project: "1"
+          project: "5"
           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:
   push:
     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:
   push:
     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:
   - pull_request
 jobs:
@@ -24,5 +24,7 @@ jobs:
         with:
           go-version-file: go.mod
           cache: false
+      - name: Run Go vet
+        run: go vet ./${{ matrix.folder }}/...
       - name: Run Go tests
         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":
   push:
-    branches:
-      - master
-name: Deploy to Porter
+    tags:
+      - production
+name: Deploy Porter to Production
 jobs:
   build-go:
     runs-on: ubuntu-latest
@@ -78,10 +78,10 @@ jobs:
         timeout-minutes: 20
         uses: porter-dev/porter-update-action@v0.1.0
         with:
-          app: production
-          cluster: "8"
+          app: porter-ui
+          cluster: "9"
           host: https://dashboard.internal-tools.getporter.dev
           namespace: default
-          project: "1"
+          project: "5"
           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
 }
+
+// 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"
 	"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/shared"
 	"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/nodes"
 	"github.com/porter-dev/porter/internal/models"
+	"github.com/porter-dev/porter/internal/telemetry"
 	v1 "k8s.io/apimachinery/pkg/apis/meta/v1"
 )
 
@@ -42,53 +43,61 @@ func NewInstallAgentHandler(
 }
 
 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")
 	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
 	}
 
-	helmAgent, err := c.GetHelmAgent(r.Context(), r, cluster, "porter-agent-system")
+	helmAgent, err := c.GetHelmAgent(ctx, r, cluster, "porter-agent-system")
 	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
 	}
 
 	err = checkAndDeleteOlderAgent(k8sAgent, helmAgent)
-
 	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
 	}
 
-	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 {
-		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
 	}
 
 	// create namespace if not exists
 	_, err = helmAgent.K8sAgent.CreateNamespace("porter-agent-system", 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
 	}
 
 	// add api token to values
 	jwt, err := token.GetTokenForAPI(user.ID, proj.ID)
 	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
 	}
 
 	encoded, err := jwt.EncodeToken(c.Config().TokenConf)
 	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
 	}
 
@@ -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)
-
 	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
 	}
 

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

@@ -1,11 +1,12 @@
 package environment
 
 import (
-	"context"
 	"errors"
 	"fmt"
 	"net/http"
 
+	"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"
@@ -33,21 +34,43 @@ func NewCreateDeploymentByClusterHandler(
 }
 
 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{}
 
 	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
 	}
 
+	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
 	env, err := c.Repo().Environment().ReadEnvironmentByOwnerRepoName(
 		project.ID, cluster.ID, request.RepoOwner, request.RepoName,
 	)
-
 	if err != nil {
+		err = telemetry.Error(ctx, span, err, "error reading environment by owner repo name")
+
 		if errors.Is(err, gorm.ErrRecordNotFound) {
 			c.HandleAPIError(w, r, apierrors.NewErrNotFound(
 				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
 	client, err := getGithubClientFromEnvironment(c.Config(), env)
-
 	if err != nil {
+		err = telemetry.Error(ctx, span, err, "error getting github client from environment")
 		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
 		return
 	}
 
 	// add a check for Github PR status
 	prClosed, err := isGithubPRClosed(client, request.RepoOwner, request.RepoName, int(request.PullRequestID))
-
 	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))
 		return
 	}
 
+	telemetry.WithAttributes(span, telemetry.AttributeKV{Key: "pr-closed", Value: prClosed})
+
 	if prClosed {
+		telemetry.WithAttributes(span, telemetry.AttributeKV{Key: "unsuccessful-exit-reason", Value: "pr is closed"})
 		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(
 			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)
-
 	if err != nil {
+		err = telemetry.Error(ctx, span, err, "error creating github deployment object")
 		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusConflict))
 		return
 	}
@@ -103,20 +129,18 @@ func (c *CreateDeploymentByClusterHandler) ServeHTTP(w http.ResponseWriter, r *h
 		PRBranchFrom:   request.GitHubMetadata.PRBranchFrom,
 		PRBranchInto:   request.GitHubMetadata.PRBranchInto,
 	})
-
 	if err != nil {
+		err = telemetry.Error(ctx, span, err, "error creating github deployment object")
 		// try to delete the GitHub deployment
-		_, err = client.Repositories.DeleteDeployment(
-			context.Background(),
+		_, deleteErr := client.Repositories.DeleteDeployment(
+			ctx,
 			env.GitRepoOwner,
 			env.GitRepoName,
 			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)))

+ 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/types"
 	"github.com/porter-dev/porter/internal/models"
+	"github.com/porter-dev/porter/internal/telemetry"
 	"gorm.io/gorm"
 )
 
@@ -34,17 +35,39 @@ func NewEnablePullRequestHandler(
 }
 
 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{}
 
 	if ok := c.DecodeAndValidate(w, r, request); !ok {
+		_ = telemetry.Error(ctx, span, nil, "could not decode and validate request")
 		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)
 	if err != nil {
+		err = telemetry.Error(ctx, span, err, "error reading environment by owner repo name")
+
 		if errors.Is(err, gorm.ErrRecordNotFound) {
 			c.HandleAPIError(w, r, apierrors.NewErrNotFound(fmt.Errorf("environment not found in cluster and project")))
 			return
@@ -67,6 +90,8 @@ func (c *EnablePullRequestHandler) ServeHTTP(w http.ResponseWriter, r *http.Requ
 		}
 
 		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(
 				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,
@@ -84,6 +109,8 @@ func (c *EnablePullRequestHandler) ServeHTTP(w http.ResponseWriter, r *http.Requ
 		}
 
 		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(
 				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,
@@ -94,26 +121,32 @@ func (c *EnablePullRequestHandler) ServeHTTP(w http.ResponseWriter, r *http.Requ
 
 	client, err := getGithubClientFromEnvironment(c.Config(), env)
 	if err != nil {
+		err = telemetry.Error(ctx, span, err, "error getting github client from environment")
+
 		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
 		return
 	}
 
 	// 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 {
+		_ = telemetry.Error(ctx, span, err, "error getting pull request")
+
 		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(fmt.Errorf("%v: %w", errGithubAPI, err),
 			http.StatusConflict))
 		return
 	}
 
 	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"),
 			http.StatusConflict))
 		return
 	}
 
 	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{
 			Ref: request.BranchFrom,
 			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 {
+		telemetry.WithAttributes(span, telemetry.AttributeKV{Key: "github-status-code", Value: ghResp.StatusCode})
 		if ghResp.StatusCode == 404 {
+			telemetry.WithAttributes(span, telemetry.AttributeKV{Key: "unsuccessful-exit-reason", Value: "bad github status code"})
 			c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(
 				fmt.Errorf(
 					"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
 		} 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(
 				fmt.Errorf(
 					"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
 	depl, err := c.Repo().Environment().CreateDeployment(&models.Deployment{
 		EnvironmentID: env.ID,
@@ -163,6 +199,7 @@ func (c *EnablePullRequestHandler) ServeHTTP(w http.ResponseWriter, r *http.Requ
 		PRBranchInto:  request.BranchInto,
 	})
 	if err != nil {
+		err = telemetry.Error(ctx, span, err, "error creating deployment in repo")
 		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
 		return
 	}

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

@@ -5,6 +5,8 @@ import (
 	"fmt"
 	"net/http"
 
+	"github.com/porter-dev/porter/internal/telemetry"
+
 	"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"
@@ -29,18 +31,29 @@ func NewGetEnvironmentHandler(
 }
 
 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 {
+		_ = telemetry.Error(ctx, span, reqErr, "could not get environment id from url")
 		c.HandleAPIError(w, r, reqErr)
 		return
 	}
 
+	telemetry.WithAttributes(span, telemetry.AttributeKV{Key: "environment-id", Value: envID})
+
 	env, err := c.Repo().Environment().ReadEnvironmentByID(project.ID, cluster.ID, envID)
 	if err != nil {
+		_ = telemetry.Error(ctx, span, err, "could not read environment by id")
 		if errors.Is(err, gorm.ErrRecordNotFound) {
 			c.HandleAPIError(w, r, apierrors.NewErrNotFound(fmt.Errorf("no such environment with ID: %d", envID)))
 			return

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

@@ -8,6 +8,8 @@ import (
 	"reflect"
 	"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"
@@ -36,24 +38,44 @@ func NewUpdateEnvironmentSettingsHandler(
 }
 
 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")
 
 	if reqErr != nil {
+		_ = telemetry.Error(ctx, span, reqErr, "could not get environment id from url")
 		c.HandleAPIError(w, r, reqErr)
 		return
 	}
 
+	telemetry.WithAttributes(span, telemetry.AttributeKV{Key: "environment-id", Value: envID})
+
 	request := &types.UpdateEnvironmentSettingsRequest{}
 
 	if ok := c.DecodeAndValidate(w, r, request); !ok {
+		_ = telemetry.Error(ctx, span, nil, "could not decode and validate request")
 		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)
 	if err != nil {
+		err = telemetry.Error(ctx, span, err, "could not read environment by id")
+
 		if errors.Is(err, gorm.ErrRecordNotFound) {
 			c.HandleAPIError(w, r, apierrors.NewErrNotFound(fmt.Errorf("no such environment with ID: %d", envID)))
 			return
@@ -91,10 +113,13 @@ func (c *UpdateEnvironmentSettingsHandler) ServeHTTP(w http.ResponseWriter, r *h
 
 	changed = !reflect.DeepEqual(env.ToEnvironmentType().GitDeployBranches, newBranches)
 
+	telemetry.WithAttributes(span, telemetry.AttributeKV{Key: "changed", Value: changed})
+
 	if changed {
 		// let us check if the webhook has access to the "push" event
 		client, err := getGithubClientFromEnvironment(c.Config(), env)
 		if err != nil {
+			err = telemetry.Error(ctx, span, err, "could not get github client")
 			c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
 			return
 		}
@@ -103,6 +128,7 @@ func (c *UpdateEnvironmentSettingsHandler) ServeHTTP(w http.ResponseWriter, r *h
 			context.Background(), env.GitRepoOwner, env.GitRepoName, env.GithubWebhookID,
 		)
 		if err != nil {
+			err = telemetry.Error(ctx, span, err, "could not get hook")
 			c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
 			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 {
 			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,
 			)
 			if err != nil {
+				err = telemetry.Error(ctx, span, err, "could not edit hook")
 				c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
 				return
 			}
@@ -140,6 +169,7 @@ func (c *UpdateEnvironmentSettingsHandler) ServeHTTP(w http.ResponseWriter, r *h
 					errString += ": " + e.Error()
 				}
 
+				_ = telemetry.Error(ctx, span, errors.New(errString), "could not auto deploy branch")
 				c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(
 					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)
 
 		if err != nil {
+			err = telemetry.Error(ctx, span, err, "could not update environment")
 			c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
 			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/porter-dev/porter/api/server/authz"
 	"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/apierrors"
 	"github.com/porter-dev/porter/api/server/shared/commonutils"
 	"github.com/porter-dev/porter/api/server/shared/config"
 	"github.com/porter-dev/porter/api/types"
+	"github.com/porter-dev/porter/internal/telemetry"
+	"gopkg.in/yaml.v2"
 )
 
 type GithubGetPorterYamlHandler struct {
@@ -31,29 +34,36 @@ func NewGithubGetPorterYamlHandler(
 }
 
 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{}
-
 	ok := c.DecodeAndValidate(w, r, request)
-
 	if !ok {
+		err := telemetry.Error(ctx, span, nil, "invalid request body")
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusBadRequest))
 		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 {
+		err := telemetry.Error(ctx, span, nil, "unable to get owner and name")
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusBadRequest))
 		return
 	}
 
 	branch, ok := commonutils.GetBranchParam(c, w, r)
-
 	if !ok {
+		err := telemetry.Error(ctx, span, nil, "unable to get branch")
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusBadRequest))
 		return
 	}
 
 	client, err := GetGithubAppClientFromRequest(c.Config(), r)
 	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
 	}
 
@@ -67,16 +77,35 @@ func (c *GithubGetPorterYamlHandler) ServeHTTP(w http.ResponseWriter, r *http.Re
 		},
 	)
 	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
 	}
 
 	fileData, err := resp.GetContent()
 	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
 	}
-	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)
 }

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

@@ -5,6 +5,8 @@ import (
 	"net/http"
 	"sync"
 
+	"github.com/porter-dev/porter/internal/telemetry"
+
 	"github.com/google/go-github/v41/github"
 	"github.com/porter-dev/porter/api/server/authz"
 	"github.com/porter-dev/porter/api/server/handlers"
@@ -30,14 +32,24 @@ func NewGithubListBranchesHandler(
 }
 
 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)
 
 	if !ok {
+		_ = telemetry.Error(ctx, span, nil, "could not get owner and name from request")
 		return
 	}
 
+	telemetry.WithAttributes(span,
+		telemetry.AttributeKV{Key: "owner", Value: owner},
+		telemetry.AttributeKV{Key: "name", Value: name},
+	)
+
 	client, err := GetGithubAppClientFromRequest(c.Config(), r)
 	if err != nil {
+		err = telemetry.Error(ctx, span, err, "could not get github app client")
 		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
 		return
 	}
@@ -49,6 +61,7 @@ func (c *GithubListBranchesHandler) ServeHTTP(w http.ResponseWriter, r *http.Req
 		},
 	})
 	if err != nil {
+		err = telemetry.Error(ctx, span, err, "could not list branches")
 		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
 		return
 	}
@@ -104,6 +117,7 @@ func (c *GithubListBranchesHandler) ServeHTTP(w http.ResponseWriter, r *http.Req
 	wg.Wait()
 
 	if workerErr != nil {
+		err = telemetry.Error(ctx, span, workerErr, "worker error listing github branches")
 		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
 		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 (
 	"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 (
 	"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 (
 	"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 (
 	"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 (
 	"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 (
 	"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{}
 
 	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 {
 		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 {
 		username := p.getCurrentUsername(user.ID, project.ID, gitlabInt)
+		glit := gitlabInt.ToGitlabIntegrationType()
 		res = append(res,
 			&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) {
-	tracer, _ := telemetry.InitTracer(r.Context(), c.Config().TelemetryConfig)
-	defer tracer.Shutdown()
-
 	user, _ := r.Context().Value(types.UserScope).(*models.User)
 	cluster, _ := r.Context().Value(types.ClusterScope).(*models.Cluster)
 	namespace := r.Context().Value(types.NamespaceScope).(string)

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

@@ -1,7 +1,6 @@
 package release
 
 import (
-	"context"
 	"fmt"
 	"net/http"
 	"net/url"
@@ -17,6 +16,7 @@ import (
 	"github.com/porter-dev/porter/internal/helm"
 	"github.com/porter-dev/porter/internal/notifier"
 	"github.com/porter-dev/porter/internal/notifier/slack"
+	"github.com/porter-dev/porter/internal/telemetry"
 	"gorm.io/gorm"
 )
 
@@ -37,57 +37,73 @@ func NewWebhookHandler(
 }
 
 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)
 
 	// retrieve release by token
-	release, err := c.Repo().Release().ReadReleaseByWebhookToken(token)
+	dbRelease, err := c.Repo().Release().ReadReleaseByWebhookToken(token)
 	if err != nil {
 		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
-			c.HandleAPIError(w, r, apierrors.NewErrForbidden(
-				fmt.Errorf("release not found with given webhook"),
-			))
-
+			c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusForbidden))
 			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
 	}
+	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)
 	if err != nil {
 		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
 			// 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
 		}
 
-		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
 	}
 
 	// in this case, we retrieve the agent by passing in the namespace field directly, since
 	// 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 {
-		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
 	}
 
 	request := &types.WebhookRequest{}
-
 	if ok := c.DecodeAndValidate(w, r, request); !ok {
 		return
 	}
 
-	rel, err := helmAgent.GetRelease(context.Background(), release.Name, 0, true)
+	rel, err := helmAgent.GetRelease(ctx, release.Name, 0, true)
 	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
 	}
 
@@ -115,17 +131,15 @@ func (c *WebhookHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
 	rel.Config["image"] = image
 
 	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
 	}
 
 	registries, err := c.Repo().Registry().ListRegistriesByProjectID(release.ProjectID)
 	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
 	}
 
@@ -141,10 +155,11 @@ func (c *WebhookHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
 
 	var notifConf *types.NotificationConfig
 	notifConf = nil
-	if release != nil && release.NotificationConfig != 0 {
+	if release.NotificationConfig != 0 {
 		conf, err := c.Repo().NotificationConfig().ReadNotificationConfig(release.NotificationConfig)
 		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
 		}
 
@@ -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 {
 		notifyOpts.Status = notifier.StatusHelmFailed
 		notifyOpts.Info = err.Error()
@@ -178,12 +192,8 @@ func (c *WebhookHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
 		if !cluster.NotificationsDisabled {
 			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
 	}
 
@@ -211,9 +221,9 @@ func (c *WebhookHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
 	c.WriteResult(w, r, nil)
 
 	err = postUpgrade(c.Config(), cluster.ProjectID, cluster.ID, rel)
-
 	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
 	}
 }

+ 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 (
 	"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/gitinstallation"
 	"github.com/porter-dev/porter/api/server/handlers/healthcheck"

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

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

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

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

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

@@ -3,7 +3,7 @@ package router
 import (
 	"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/gitinstallation"
 	"github.com/porter-dev/porter/api/server/shared"
@@ -735,5 +735,72 @@ func getGitInstallationRoutes(
 		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
 }

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

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

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

@@ -3,7 +3,7 @@ package router
 import (
 	"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/infra"
 	"github.com/porter-dev/porter/api/server/shared"

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

@@ -1,7 +1,7 @@
 package router
 
 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/shared"
 	"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
 
 import (
-	"fmt"
 	"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/config"
 	"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 {
 	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
 		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 {
+			err = telemetry.Error(ctx, span, err, "error getting usage")
+
 			apierrors.HandleAPIError(
 				b.config.Logger,
 				b.config.Alerter,
@@ -45,25 +54,25 @@ func (b *UsageMiddleware) Middleware(next http.Handler) http.Handler {
 			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
 		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 (
 	"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/namespace"

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

@@ -1,7 +1,7 @@
 package router
 
 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/shared"
 	"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 (
 	"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/config"
 	"github.com/porter-dev/porter/api/server/shared/router"
@@ -55,7 +55,7 @@ func getStackRoutes(
 
 	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(
 		&types.APIRequestMetadata{
 			Verb:   types.APIVerbGet,
@@ -72,7 +72,7 @@ func getStackRoutes(
 		},
 	)
 
-	getPorterAppHandler := stacks.NewGetPorterAppHandler(
+	getPorterAppHandler := porter_app.NewGetPorterAppHandler(
 		config,
 		factory.GetResultWriter(),
 	)
@@ -83,7 +83,7 @@ func getStackRoutes(
 		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(
 		&types.APIRequestMetadata{
 			Verb:   types.APIVerbList,
@@ -100,7 +100,7 @@ func getStackRoutes(
 		},
 	)
 
-	listPorterAppHandler := stacks.NewPorterAppListHandler(
+	listPorterAppHandler := porter_app.NewPorterAppListHandler(
 		config,
 		factory.GetResultWriter(),
 	)
@@ -128,7 +128,7 @@ func getStackRoutes(
 		},
 	)
 
-	deletePorterAppByNameHandler := stacks.NewDeletePorterAppByNameHandler(
+	deletePorterAppByNameHandler := porter_app.NewDeletePorterAppByNameHandler(
 		config,
 		factory.GetDecoderValidator(),
 		factory.GetResultWriter(),
@@ -140,7 +140,7 @@ func getStackRoutes(
 		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(
 		&types.APIRequestMetadata{
 			Verb:   types.APIVerbCreate,
@@ -157,7 +157,7 @@ func getStackRoutes(
 		},
 	)
 
-	createPorterAppHandler := stacks.NewCreatePorterAppHandler(
+	createPorterAppHandler := porter_app.NewCreatePorterAppHandler(
 		config,
 		factory.GetDecoderValidator(),
 		factory.GetResultWriter(),
@@ -169,7 +169,36 @@ func getStackRoutes(
 		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(
 		&types.APIRequestMetadata{
 			Verb:   types.APIVerbCreate,
@@ -186,7 +215,7 @@ func getStackRoutes(
 		},
 	)
 
-	createSecretAndOpenGitHubPullRequestHandler := stacks.NewOpenStackPRHandler(
+	createSecretAndOpenGitHubPullRequestHandler := porter_app.NewOpenStackPRHandler(
 		config,
 		factory.GetDecoderValidator(),
 		factory.GetResultWriter(),
@@ -198,7 +227,7 @@ func getStackRoutes(
 		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(
 		&types.APIRequestMetadata{
 			Verb:   types.APIVerbList,
@@ -215,7 +244,7 @@ func getStackRoutes(
 		},
 	)
 
-	listPorterAppEventsHandler := stacks.NewPorterAppEventListHandler(
+	listPorterAppEventsHandler := porter_app.NewPorterAppEventListHandler(
 		config,
 		factory.GetResultWriter(),
 	)
@@ -226,14 +255,14 @@ func getStackRoutes(
 		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{
-			Verb:   types.APIVerbGet,
-			Method: types.HTTPVerbGet,
+			Verb:   types.APIVerbCreate,
+			Method: types.HTTPVerbPost,
 			Path: &types.Path{
 				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{
 				types.UserScope,
@@ -243,18 +272,19 @@ func getStackRoutes(
 		},
 	)
 
-	getPorterAppEventHandler := stacks.NewGetPorterAppEventHandler(
+	createPorterAppEventHandler := porter_app.NewCreateUpdatePorterAppEventHandler(
 		config,
+		factory.GetDecoderValidator(),
 		factory.GetResultWriter(),
 	)
 
 	routes = append(routes, &router.Route{
-		Endpoint: getPorterAppEventEndpoint,
-		Handler:  getPorterAppEventHandler,
+		Endpoint: createPorterAppEventEndpoint,
+		Handler:  createPorterAppEventHandler,
 		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(
 		&types.APIRequestMetadata{
 			Verb:   types.APIVerbUpdate,
@@ -271,7 +301,7 @@ func getStackRoutes(
 		},
 	)
 
-	porterAppAnalyticsHandler := stacks.NewPorterAppAnalyticsHandler(
+	porterAppAnalyticsHandler := porter_app.NewPorterAppAnalyticsHandler(
 		config,
 		factory.GetDecoderValidator(),
 		factory.GetResultWriter(),
@@ -283,5 +313,34 @@ func getStackRoutes(
 		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
 }

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

@@ -3,7 +3,7 @@ package router
 import (
 	"fmt"
 
-	"github.com/go-chi/chi"
+	"github.com/go-chi/chi/v5"
 	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/billing"

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

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

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

@@ -1,7 +1,7 @@
 package router
 
 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/shared"

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

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

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

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

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

@@ -6,8 +6,8 @@ import (
 	"path"
 	"strings"
 
-	"github.com/go-chi/chi"
 	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/authz"
 	"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/router"
 	"github.com/porter-dev/porter/api/types"
+	"github.com/riandyrn/otelchi"
 )
 
 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) {
-		// 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(
 			r,
@@ -110,11 +111,11 @@ func NewAPIRouter(config *config.Config) *chi.Mux {
 	})
 
 	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
 
@@ -294,10 +295,11 @@ func registerRoutes(config *config.Config, routes []*router.Route) {
 
 		if route.Endpoint.Metadata.CheckUsage && config.ServerConf.UsageTrackingEnabled {
 			usageMW := middleware.NewUsageMiddleware(config, route.Endpoint.Metadata.UsageMetric)
-
 			atomicGroup.Use(usageMW.Middleware)
 		}
 
+		atomicGroup.Use(middleware.HydrateTraces)
+
 		atomicGroup.Method(
 			string(route.Endpoint.Metadata.Method),
 			route.Endpoint.Metadata.Path.RelativePath,

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

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

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

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

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

@@ -3,7 +3,7 @@ package router
 import (
 	"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/project"
 	"github.com/porter-dev/porter/api/server/handlers/template"

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

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

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

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

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

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

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

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

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

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

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

@@ -10,7 +10,7 @@ import (
 	"strings"
 	"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/apierrors"
 	"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"
 	"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/types"
 )

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

@@ -6,7 +6,7 @@ import (
 	"net/http/httptest"
 	"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/apitest"
 	"github.com/porter-dev/porter/api/server/shared/requestutils"

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

@@ -3,7 +3,7 @@ package router
 import (
 	"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/config"
 	"github.com/porter-dev/porter/api/types"

+ 16 - 3
api/types/incident.go

@@ -112,6 +112,19 @@ type GetLogRequest struct {
 	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 {
 	StartRange  *time.Time `schema:"start_range"`
 	EndRange    *time.Time `schema:"end_range"`
@@ -132,9 +145,9 @@ type LogLine 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 {

+ 21 - 1
api/types/porter_app.go

@@ -58,6 +58,10 @@ type UpdatePorterAppRequest struct {
 	PullRequestURL string `json:"pull_request_url"`
 }
 
+type RollbackPorterAppRequest struct {
+	Revision int `json:"revision" form:"required"`
+}
+
 type ListPorterAppResponse []*PorterApp
 
 // 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 time.Time `json:"updated_at"`
 	// 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"`
 }
 
@@ -86,6 +90,22 @@ const (
 	PorterAppEventType_Build PorterAppEventType = "BUILD"
 	// PorterAppEventType_Deploy represents a Porter Stack Deploy event which occurred through the Porter UI or CLI
 	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 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 (
 	"context"
 	"encoding/json"
+	"errors"
 	"fmt"
 	"io/ioutil"
 	"net/url"
@@ -10,6 +11,7 @@ import (
 	"path/filepath"
 	"strconv"
 	"strings"
+	"time"
 
 	"github.com/cli/cli/git"
 	"github.com/fatih/color"
@@ -106,7 +108,7 @@ func init() {
 	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)
 	if err != nil {
 		stackName := os.Getenv("PORTER_STACK_NAME")
@@ -177,49 +179,120 @@ func apply(_ *types.GetAuthenticatedUserResponse, client *api.Client, _ []string
 			Builder:              builder,
 		}
 		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 {
 		return fmt.Errorf("unknown porter.yaml version: %s", previewVersion.Version)
 	}
 
 	basePath, err := os.Getwd()
 	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")
 
 	if hasDeploymentHookEnvVars() {
 		deplNamespace := os.Getenv("PORTER_NAMESPACE")
 
 		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)
 		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)
-	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)
-	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,
 	})
+	return
 }
 
 func applyValidate() error {
@@ -316,10 +389,11 @@ func (d *DeployDriver) ShouldApply(_ *switchboardModels.Resource) bool {
 }
 
 func (d *DeployDriver) Apply(resource *switchboardModels.Resource) (*switchboardModels.Resource, error) {
+	ctx := context.Background()
 	client := config.GetAPIClient()
 
 	_, err := client.GetRelease(
-		context.Background(),
+		ctx,
 		d.target.Project,
 		d.target.Cluster,
 		d.target.Namespace,
@@ -333,7 +407,7 @@ func (d *DeployDriver) Apply(resource *switchboardModels.Resource) (*switchboard
 	}
 
 	if d.source.IsApplication {
-		return d.applyApplication(resource, client, shouldCreate)
+		return d.applyApplication(ctx, resource, client, shouldCreate)
 	}
 
 	return d.applyAddon(resource, client, shouldCreate)
@@ -394,7 +468,7 @@ func (d *DeployDriver) applyAddon(resource *switchboardModels.Resource, client *
 	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 {
 		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) {
 		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{
 			ProjectID: d.target.Project,
 			ClusterID: d.target.Cluster,
 			Namespace: d.target.Namespace,
 			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)
 		}
+
+		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

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

+ 9 - 0
cmd/app/main.go

@@ -3,6 +3,7 @@
 package main
 
 import (
+	"context"
 	"errors"
 	"flag"
 	"fmt"
@@ -10,6 +11,8 @@ import (
 	"net/http"
 	"os"
 
+	"github.com/porter-dev/porter/internal/telemetry"
+
 	"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/loader"
@@ -61,6 +64,12 @@ func main() {
 		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 {
 		config.Logger.Fatal().Err(err).Msg("Server startup failed")
 	}

+ 1 - 1
cmd/dev/main.go

@@ -5,7 +5,7 @@ import (
 	"net/http"
 	"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/shared/apitest"
 )

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


+ 2 - 0
dashboard/package.json

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

+ 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 {
   selectedDate?: Date;
   setSelectedDate: React.Dispatch<React.SetStateAction<Date>>;
+  resetSearch: () => void;
 }
 
 const LogQueryModeSelectionToggle = (
@@ -22,7 +23,10 @@ const LogQueryModeSelectionToggle = (
     >
       <ToggleButton>
         <ToggleOption
-          onClick={() => props.setSelectedDate(undefined)}
+          onClick={() => {
+            props.setSelectedDate(undefined);
+            props.resetSearch();
+          }}
           selected={!props.selectedDate}
         >
           <Dot selected={!props.selectedDate} />

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

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

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

@@ -95,7 +95,7 @@ const RadioFilter: React.FC<Props> = (props) => {
         <TextAlt>{props.name}</TextAlt>
         <Bar />
         <Selected>
-          {props.selected
+          {props.selected != null
             ? props.selected === ""
               ? "All"
               : getLabel(props.selected)
@@ -126,6 +126,15 @@ const Selected = styled.div`
   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`
   overflow: hidden;
   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 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 (
     <DateTimePickerWrapper
       onClick={(e) => {
@@ -25,6 +46,9 @@ const DateTimePicker: React.FC<Props> = ({ startDate, setStartDate }) => {
         onChange={(date: any) => setStartDate(date)}
         showTimeSelect
         dateFormat="MMMM d, yyyy h:mm aa"
+        includeDates={availableDates}
+        maxTime={maxTime}
+        minTime={minTime}
       />
     </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 {
   display: none;
 }
@@ -358,7 +370,8 @@
   ul.react-datepicker__time-list
   li.react-datepicker__time-list-item:hover {
   cursor: pointer;
-  background-color: #26292e;
+  background-color: #525882;
+  border-radius: 0.3rem
 }
 .react-datepicker__time-container
   .react-datepicker__time
@@ -368,6 +381,7 @@
   background-color: #949eff;
   color: white;
   font-weight: bold;
+  border-radius: 0.3rem
 }
 .react-datepicker__time-container
   .react-datepicker__time
@@ -406,6 +420,9 @@
   border-radius: 0.3rem;
   background-color: #26292e;
 }
+.react-datepicker__month {
+  cursor: default
+}
 .react-datepicker__day-names,
 .react-datepicker__week {
   white-space: nowrap;
@@ -457,7 +474,7 @@
 .react-datepicker__month-text:hover,
 .react-datepicker__quarter-text:hover {
   border-radius: 0.3rem;
-  background-color: #26292e;
+  background-color: #525882;
 }
 .react-datepicker__day--today,
 .react-datepicker__month-text--today,
@@ -539,7 +556,9 @@
 .react-datepicker__month-text--disabled,
 .react-datepicker__quarter-text--disabled {
   cursor: default;
-  color: #ccc;
+  color: #999;
+  background-color: transparent;
+  cursor: not-allowed;
 }
 .react-datepicker__day--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 (
     disabled && disabledTooltip ?
       <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>
           </Box>
           {children}
         </StyledCheckbox>
       </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>
         </Box>
         {children}
@@ -47,14 +45,16 @@ const Checkbox: React.FC<Props> = ({
 
 export default Checkbox;
 
-const StyledCheckbox = styled.div`
+const StyledCheckbox = styled.div<{
+  disabled?: boolean;
+}>`
   display: flex;
   align-items: center;
+  cursor: ${(props) => (props.disabled ? "not-allowed" : "pointer")};
 `;
 
 const Box = styled.div<{
   checked: boolean;
-  disabled?: boolean;
 }>`
   width: 12px;
   height: 12px;
@@ -65,7 +65,6 @@ const Box = styled.div<{
   display: flex;
   align-items: center;
   justify-content: center;
-  cursor: ${(props) => (props.disabled ? "not-allowed" : "pointer")};
 
   > i {
     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 { createPortal } from "react-dom";
 import styled from "styled-components";
@@ -6,23 +7,31 @@ type Props = {
   message: string;
   onYes: React.MouseEventHandler;
   onNo: React.MouseEventHandler;
+  loading?: boolean;
 };
 
-const TemplateComponent: React.FC<Props> = ({
+const ConfirmOverlay: React.FC<Props> = ({
   message,
   onYes,
   onNo,
+  loading,
 }) => {
   return (
     <>
       {
         createPortal(
           <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>,
           document.body
         )
@@ -31,7 +40,7 @@ const TemplateComponent: React.FC<Props> = ({
   );
 };
 
-export default TemplateComponent;
+export default ConfirmOverlay;
 
 const StyledConfirmOverlay = styled.div`
   position: absolute;

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

@@ -4,13 +4,17 @@ import styled from "styled-components";
 type Props = {
   children: React.ReactNode;
   row?: boolean;
+  column?: boolean;
   spaced?: boolean;
+  alignItems?: string;
 };
 
 const Container: React.FC<Props> = ({
   children,
   row,
   spaced,
+  column,
+  alignItems,
 }) => {
   const [isExpanded, setIsExpanded] = useState(false);
 
@@ -18,6 +22,8 @@ const Container: React.FC<Props> = ({
     <StyledContainer
       spaced={spaced}
       row={row}
+      column={column}
+      alignItems={alignItems}
     >
       {children}
     </StyledContainer>
@@ -27,10 +33,13 @@ const Container: React.FC<Props> = ({
 export default Container;
 
 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"};
 `;

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

@@ -4,19 +4,25 @@ import styled from "styled-components";
 type Props = {
   src: any;
   height?: string;
+  opacity?: number;
 };
 
 const Icon: React.FC<Props> = ({
   src,
   height,
+  opacity,
 }) => {
   return (
-    <StyledIcon src={src} height={height} />
+    <StyledIcon src={src} height={height} opacity={opacity} />
   );
 };
 
 export default Icon;
 
-const StyledIcon = styled.img<{ height?: string}>`
+const StyledIcon = styled.img<{ 
+  height?: string;
+  opacity?: number;
+}>`
   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 styled from "styled-components";
 
+import Icon from "components/porter/Icon";
+
 type Props = {
   to?: string;
   onClick?: () => void;
@@ -18,35 +20,53 @@ const Link: React.FC<Props> = ({
   hasunderline,
 }) => {
   return (
-    <>
+    <LinkWrapper>
       {to ? (
-        <StyledLink 
-          to={to} 
-          target={target}
-          hasunderline={hasunderline}
-        >
+        <StyledLink to={to} target={target}>
           {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>
       ) : (
-        <Div 
-          onClick={onClick}
-          hasunderline={hasunderline}
-        >
+        <Div onClick={onClick}>
           {children}
         </Div>
       )}
-    </>
+      {hasunderline && <Underline />}
+    </LinkWrapper>
   );
 };
 
 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;
   cursor: pointer;
   font-size: 13px;
   display: inline-flex;
-  border-bottom: ${props => props.hasunderline ? "1px solid #fff" : ""};
 `;
 
 const StyledLink = styled(DynamicLink)<{ hasunderline?: boolean }>`
@@ -54,6 +74,4 @@ const StyledLink = styled(DynamicLink)<{ hasunderline?: boolean }>`
   display: inline-flex;
   font-size: 13px;
   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;
   weight?: number;
   children: any;
+  additionalStyles?: string;
 };
 
 const Text: React.FC<Props> = ({
   size,
   weight,
   color,
-  children
+  children,
+  additionalStyles
 }) => {
   const getColor = () => {
     switch (color) {
@@ -22,12 +24,13 @@ const Text: React.FC<Props> = ({
         return color;
     }
   };
-  
+
   return (
     <StyledText
       size={size}
       color={getColor()}
       weight={weight}
+      additionalStyles={additionalStyles}
     >
       {children}
     </StyledText>
@@ -36,10 +39,11 @@ const Text: React.FC<Props> = ({
 
 export default Text;
 
-const StyledText = styled.div<{ 
-  size?: number; 
+const StyledText = styled.div<{
+  size?: number;
   color?: string;
   weight?: number;
+  additionalStyles?: string;
 }>`
   line-height: 1.5;
   font-weight: ${props => props.weight || 400};
@@ -47,4 +51,5 @@ const StyledText = styled.div<{
   font-size: ${props => props.size || 13}px;
   display: inline;
   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 Text from "components/porter/Text";
 import { set } from "lodash";
+import Link from "../porter/Link";
 
 interface AutoBuildpack {
   name?: string;
@@ -188,7 +189,7 @@ const DetectContentsList: React.FC<PropsType> = (props) => {
         );
         return res;
       } catch (err) {
-        console.log(err);
+        // console.log(err);
       }
     } else if (actionConfig.kind === "gitlab") {
       try {
@@ -310,19 +311,17 @@ const DetectContentsList: React.FC<PropsType> = (props) => {
       <Spacer y={1} />
       <span>
         <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"
-          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>
     </div>
   );
@@ -409,6 +408,10 @@ const DetectContentsList: React.FC<PropsType> = (props) => {
 
 export default DetectContentsList;
 
+const Code = styled.span`
+  font-family: monospace;
+`;
+
 const FlexWrapper = styled.div`
   position: absolute;
   bottom: 28px;
@@ -588,7 +591,7 @@ const Item = styled.div`
   font-size: 13px;
   border-bottom: 1px solid
     ${(props: { lastItem: boolean; isSelected?: boolean }) =>
-      props.lastItem ? "#00000000" : "#606166"};
+    props.lastItem ? "#00000000" : "#606166"};
   color: #ffffff;
   user-select: none;
   align-items: center;
@@ -619,7 +622,7 @@ const FileItem = styled(Item)`
     props.isADocker ? "#fff" : "#ffffff55"};
   :hover {
     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 Fieldset from "components/porter/Fieldset";
 import Select from "components/porter/Select";
+import ClusterProvisioningPlaceholder from "components/ClusterProvisioningPlaceholder";
 
 type Props = {
 };
@@ -45,6 +46,7 @@ const namespaceBlacklist = [
   "kube-public",
   "kube-system",
   "monitoring",
+  "porter-agent-system",
 ];
 
 const templateBlacklist = [
@@ -148,90 +150,96 @@ const AppDashboard: React.FC<Props> = ({
         description="Add-ons and supporting workloads for this project."
         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} />
     </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}
               />
             }
-            title={`Configure new ${hardcodedNames[currentTemplate.name] || currentTemplate.name} instance`}
+            title={`Configure new "${hardcodedNames[currentTemplate.name] || currentTemplate.name}" instance`}
             capitalize={false}
             disableLineBreak
           />
@@ -194,7 +194,7 @@ const ConfigureTemplate: React.FC<Props> = ({
                 <Text size={16}>Add-on name</Text>
                 <Spacer y={0.5} />
                 <Text color="helper">
-                  Randomly generated if left blank (lowercase letters, numbers, and "-" only).
+                  Lowercase letters, numbers, and "-" only.
                 </Text>
                 <Spacer height="20px" />
                 <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 Loading from "components/Loading";
 import Fieldset from "components/porter/Fieldset";
+import ClusterProvisioningPlaceholder from "components/ClusterProvisioningPlaceholder";
+import Icon from "components/porter/Icon";
 
 type Props = {};
 
@@ -49,7 +51,7 @@ const namespaceBlacklist = [
 ];
 
 const AppDashboard: React.FC<Props> = ({ }) => {
-  const { currentProject, currentCluster } = useContext(Context);
+  const { currentProject, currentCluster, setFeaturePreview } = useContext(Context);
   const [apps, setApps] = useState([]);
   const [charts, setCharts] = useState([]);
   const [error, setError] = useState(null);
@@ -130,19 +132,19 @@ const AppDashboard: React.FC<Props> = ({ }) => {
     return (
       <>
         {app.repo_name ? (
-          <>
+          <Container row>
             <SmallIcon opacity="0.6" src={github} />
-            {app.repo_name}
-          </>
+            <Text size={13} color="#ffffff44">{app.repo_name}</Text>
+          </Container>
         ) : (
-          <>
+          <Container row>
             <SmallIcon
               opacity="0.7"
               height="18px"
               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 (
-      <>{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."
         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} />
     </StyledAppDashboard>
@@ -334,17 +348,6 @@ const StatusIcon = styled.img`
   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 }>`
   margin-left: 2px;
   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 web from "assets/web.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 loadingImg from "assets/loading.gif";
 import refresh from "assets/refresh.png";
+<<<<<<< HEAD
 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 JSZip from "jszip";
 import { Context } from "shared/Context";
 import useAuth from "shared/auth/useAuth";
 import Error from "components/porter/Error";
@@ -27,6 +34,7 @@ import Spacer from "components/porter/Spacer";
 import Link from "components/porter/Link";
 import Back from "components/porter/Back";
 import TabSelector from "components/TabSelector";
+import Icon from "components/porter/Icon";
 import { ChartType, PorterAppOptions, ResourceType } from "shared/types";
 import RevisionSection from "main/home/cluster-dashboard/expanded-chart/RevisionSection";
 import BuildSettingsTabStack from "./BuildSettingsTabStack";
@@ -43,13 +51,21 @@ import { PorterYamlSchema } from "../new-app-flow/schema";
 import { EnvVariablesTab } from "./EnvVariablesTab";
 import GHABanner from "./GHABanner";
 import LogSection from "./LogSection";
-import EventsTab from "./EventsTab";
 import ActivityFeed from "./activity-feed/ActivityFeed";
 import JobRuns from "./JobRuns";
 import MetricsSection from "./MetricsSection";
 import StatusSectionFC from "./status/StatusSection";
 import ExpandedJob from "./expanded-job/ExpandedJob";
+<<<<<<< HEAD
 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 & {};
 
@@ -62,9 +78,12 @@ const icons = [
 ];
 
 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 [deleting, setDeleting] = useState(false);
   const [appData, setAppData] = useState(null);
@@ -77,22 +96,30 @@ const ExpandedApp: React.FC<Props> = ({ ...props }) => {
   const [forceRefreshRevisions, setForceRefreshRevisions] = useState<boolean>(
     false
   );
-  const [isLoadingChartData, setIsLoadingChartData] = useState<boolean>(true);
 
   const [tab, setTab] = useState("activity");
+<<<<<<< HEAD
   const [saveValuesStatus, setSaveValueStatus] = useState<string>(null);
+=======
+  const [saveValuesStatus, setSaveValueStatus] = useState<string>("");
+>>>>>>> eff3d25f3e5dc46e0feb70c1af18b6f1d91c69e5
   const [loading, setLoading] = useState<boolean>(false);
-  const [components, setComponents] = useState<ResourceType[]>([]);
+  const [bannerLoading, setBannerLoading] = useState<boolean>(false);
 
   const [showRevisions, setShowRevisions] = 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 [logs, setLogs] = useState<Log[]>([]);
+  const [modalVisible, setModalVisible] = useState(false);
 
   const [services, setServices] = useState<Service[]>([]);
-  const [releaseJob, setReleaseJob] = useState<ReleaseService[]>([]);
   const [envVars, setEnvVars] = useState<KeyValueType[]>([]);
   const [buttonStatus, setButtonStatus] = useState<React.ReactNode>("");
   const [subdomain, setSubdomain] = useState<string>("");
@@ -100,8 +127,9 @@ const ExpandedApp: React.FC<Props> = ({ ...props }) => {
 
   const [showRevisionModal, setShowRevisionModal] = useState<boolean>(false);
 
+
   const getPorterApp = async () => {
-    // setIsLoading(true);
+    setBannerLoading(true);
     const { appName } = props.match.params as any;
     try {
       if (!currentCluster || !currentProject) {
@@ -160,10 +188,23 @@ const ExpandedApp: React.FC<Props> = ({ ...props }) => {
 
       setPorterJson(porterJson);
       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
-      const hasBuiltImage = !!resChartData.data.config?.global?.image?.repository;
+      const hasBuiltImage = !!resChartData.data.config?.global?.image
+        ?.repository;
       if (hasBuiltImage || !resPorterApp.data.repo_name) {
         setWorkflowCheckPassed(true);
         setHasBuiltImage(true);
@@ -258,7 +299,6 @@ const ExpandedApp: React.FC<Props> = ({ ...props }) => {
       ) {
         const finalPorterYaml = createFinalPorterYaml(
           services,
-          releaseJob,
           envVars,
           porterJson,
           // 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,
           }
         );
+        setPorterYaml(finalPorterYaml);
         setButtonStatus("success");
+        setShowUnsavedChangesBanner(false);
       } else {
         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 (
     porterYaml: string,
     appData: any
@@ -346,29 +459,31 @@ const ExpandedApp: React.FC<Props> = ({ ...props }) => {
           break;
       }
     }
-    return <Icon src={src} />;
+    return <Icon src={src} height={"24px"} />;
   };
 
-  const updateServicesAndEnvVariables = async (
+  const updateServicesAndEnvVariables = (
     currentChart?: ChartType,
     releaseChart?: ChartType,
-    porterJson?: PorterJson
-  ) => {
+    porterJson?: PorterJson,
+  ): [Service[], KeyValueType[]] => {
     // handle normal chart
     const helmValues = currentChart?.config;
     const defaultValues = (currentChart?.chart as any)?.values;
+    let newServices: Service[] = [];
+    let envVars: KeyValueType[] = [];
+
     if (
       (defaultValues && Object.keys(defaultValues).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;
       if (Object.keys(helmValuesWithoutGlobal).length > 0) {
-        const envs = Service.retrieveEnvFromHelmValues(helmValuesWithoutGlobal);
-        setEnvVars(envs);
+        envVars = Service.retrieveEnvFromHelmValues(helmValuesWithoutGlobal);
+        setEnvVars(envVars);
         const subdomain = Service.retrieveSubdomainFromHelmValues(
-          svcs,
+          newServices,
           helmValuesWithoutGlobal
         );
         setSubdomain(subdomain);
@@ -377,96 +492,87 @@ const ExpandedApp: React.FC<Props> = ({ ...props }) => {
 
     // handle release chart
     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 {
-      const res = await api.getChartComponents(
+      const res = await api.getChart(
         "<token>",
         {},
         {
-          id: currentProject.id,
-          name: currentChart.name,
-          namespace: currentChart.namespace,
+          name: chart.name,
+          namespace: chart.namespace,
           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(
@@ -541,11 +647,56 @@ const ExpandedApp: React.FC<Props> = ({ ...props }) => {
     });
     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 = () => {
     switch (tab) {
       case "overview":
         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 && (
               <>
                 <Fieldset>
@@ -558,18 +709,21 @@ const ExpandedApp: React.FC<Props> = ({ ...props }) => {
               </>
             )}
             <Services
-              setServices={(x) => {
+              setServices={(svcs: Service[]) => {
                 if (buttonStatus !== "") {
                   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}
-              services={services}
               addNewText={"Add a new service"}
               setExpandedJob={(x: string) => setExpandedJob(x)}
             />
-            <Spacer y={1} />
+            <Spacer y={0.75} />
             <Button
               onClick={async () => await updatePorterApp({})}
               status={buttonStatus}
@@ -612,9 +766,15 @@ const ExpandedApp: React.FC<Props> = ({ ...props }) => {
       case "events":
         return <EventsTab currentChart={appData.chart} />;
       case "activity":
-        return <ActivityFeed chart={appData.chart} stackName={appData?.app?.name}/>;
+        return (
+          <ActivityFeed
+            chart={appData.chart}
+            stackName={appData?.app?.name}
+            appData={appData}
+          />
+        );
       case "logs":
-        return <LogSection currentChart={appData.chart} />;
+        return <LogSection currentChart={appData.chart} services={services} />;
       case "metrics":
         return <MetricsSection currentChart={appData.chart} />;
       case "status":
@@ -623,7 +783,10 @@ const ExpandedApp: React.FC<Props> = ({ ...props }) => {
         return (
           <EnvVariablesTab
             envVars={envVars}
-            setEnvVars={setEnvVars}
+            setEnvVars={(envVars: KeyValueType[]) => {
+              setEnvVars(envVars);
+              onAppUpdate(services, envVars.filter((e) => e.key !== "" || e.value !== ""));
+            }}
             status={buttonStatus}
             updatePorterApp={updatePorterApp}
             clearStatus={() => setButtonStatus("")}
@@ -632,53 +795,30 @@ const ExpandedApp: React.FC<Props> = ({ ...props }) => {
       case "pre-deploy":
         return (
           <>
-            {!isLoading && releaseJob.length === 0 && (
+            {!isLoading && !services.some(Service.isRelease) && (
               <>
                 <Fieldset>
                   <Container row>
                     <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>
                 </Fieldset>
                 <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:
@@ -688,12 +828,12 @@ const ExpandedApp: React.FC<Props> = ({ ...props }) => {
 
   if (expandedJob) {
     return (
-      <ExpandedJob 
+      <ExpandedJob
         appName={appData.app.name}
         jobName={expandedJob}
         goBack={() => setExpandedJob(null)}
       />
-    )
+    );
   }
 
   return (
@@ -717,20 +857,19 @@ const ExpandedApp: React.FC<Props> = ({ ...props }) => {
           <Back to="/apps" />
           <Container row>
             {renderIcon(appData.app?.build_packs)}
+            <Spacer inline x={1} />
             <Text size={21}>{appData.app.name}</Text>
             {appData.app.repo_name && (
               <>
                 <Spacer inline x={1} />
                 <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>
               </>
             )}
@@ -810,6 +949,7 @@ const ExpandedApp: React.FC<Props> = ({ ...props }) => {
           ) : (
             <>
               {!workflowCheckPassed ? (
+<<<<<<< HEAD
                 <>
                   <GHABanner
                     repoName={appData.app.repo_name}
@@ -818,6 +958,70 @@ const ExpandedApp: React.FC<Props> = ({ ...props }) => {
                     stackName={appData.app.name}
                     gitRepoId={appData.app.git_repo_id}
                     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} />
                 </>
@@ -843,6 +1047,7 @@ const ExpandedApp: React.FC<Props> = ({ ...props }) => {
                   <Spacer y={1} />
                 </>
               )}
+<<<<<<< HEAD
               <TabSelector
                 options={
                   appData.app.git_repo_id
@@ -888,6 +1093,49 @@ const ExpandedApp: React.FC<Props> = ({ ...props }) => {
                       { 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}
                 setCurrentTab={(tab: string) => {
                   if (buttonStatus !== "") {
@@ -950,8 +1198,17 @@ const ExpandedApp: React.FC<Props> = ({ ...props }) => {
 
 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`
-  color: #ffffff44;
+  color: #ffffff;
   display: flex;
   align-items: center;
   cursor: pointer;
@@ -968,7 +1225,28 @@ const RefreshButton = styled.div`
     justify-content: center;
     height: 11px;
     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;
 `;
 
-const BranchSection = styled.div`
-  background: ${(props) => props.theme.fg};
-  border: 1px solid #494b4f;
-`;
-
 const SmallIcon = styled.img<{ opacity?: string; height?: string }>`
   height: ${(props) => props.height || "15px"};
   opacity: ${(props) => props.opacity || 1};
@@ -1032,11 +1305,6 @@ const BranchIcon = styled.img`
   margin-right: 5px;
 `;
 
-const Icon = styled.img`
-  height: 24px;
-  margin-right: 15px;
-`;
-
 const PlaceholderIcon = styled.img`
   height: 13px;
   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;
-`;

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