Explorar o código

Merge remote-tracking branch 'origin/master' into feat/env-creation-apply-step

Ian Edwards %!s(int64=2) %!d(string=hai) anos
pai
achega
088e644b08
Modificáronse 39 ficheiros con 1913 adicións e 560 borrados
  1. 8 0
      api/server/handlers/cluster/detect_agent_installed.go
  2. 1 0
      api/server/handlers/porter_app/analytics.go
  3. 6 6
      api/server/handlers/porter_app/create.go
  4. 3 0
      api/server/handlers/porter_app/create_and_update_events.go
  5. 64 74
      api/server/handlers/porter_app/create_secret_and_open_pr.go
  6. 71 6
      api/server/handlers/porter_app/rollback.go
  7. 1 0
      api/types/agent.go
  8. 5 3
      api/types/stack.go
  9. 3 0
      dashboard/src/assets/canceled.svg
  10. 3 0
      dashboard/src/assets/failure.svg
  11. 1 0
      dashboard/src/assets/filter-outline-icon.svg
  12. BIN=BIN
      dashboard/src/assets/filter-outline-new.png
  13. 0 3
      dashboard/src/main/home/Home.tsx
  14. 67 0
      dashboard/src/main/home/app-dashboard/expanded-app/DeleteApplicationModal.tsx
  15. 93 69
      dashboard/src/main/home/app-dashboard/expanded-app/ExpandedApp.tsx
  16. 547 0
      dashboard/src/main/home/app-dashboard/expanded-app/PorterAppRevisionSection.tsx
  17. 57 0
      dashboard/src/main/home/app-dashboard/expanded-app/SettingsTab.tsx
  18. 51 31
      dashboard/src/main/home/app-dashboard/expanded-app/activity-feed/ActivityFeed.tsx
  19. 5 27
      dashboard/src/main/home/app-dashboard/expanded-app/activity-feed/events/cards/AppEventCard.tsx
  20. 8 5
      dashboard/src/main/home/app-dashboard/expanded-app/activity-feed/events/cards/BuildEventCard.tsx
  21. 146 35
      dashboard/src/main/home/app-dashboard/expanded-app/activity-feed/events/cards/DeployEventCard.tsx
  22. 2 2
      dashboard/src/main/home/app-dashboard/expanded-app/activity-feed/events/cards/EventCard.tsx
  23. 3 3
      dashboard/src/main/home/app-dashboard/expanded-app/activity-feed/events/cards/PreDeployEventCard.tsx
  24. 3 3
      dashboard/src/main/home/app-dashboard/expanded-app/activity-feed/events/focus-views/BuildFailureEventFocusView.tsx
  25. 71 0
      dashboard/src/main/home/app-dashboard/expanded-app/activity-feed/events/focus-views/DeployEventFocusView.tsx
  26. 12 2
      dashboard/src/main/home/app-dashboard/expanded-app/activity-feed/events/focus-views/EventFocusView.tsx
  27. 4 2
      dashboard/src/main/home/app-dashboard/expanded-app/activity-feed/events/focus-views/PredeployEventFocusView.tsx
  28. 30 0
      dashboard/src/main/home/app-dashboard/expanded-app/activity-feed/events/types.ts
  29. 5 2
      dashboard/src/main/home/app-dashboard/expanded-app/activity-feed/events/utils.ts
  30. 45 0
      dashboard/src/main/home/app-dashboard/expanded-app/logs/LogFilterComponent.tsx
  31. 72 0
      dashboard/src/main/home/app-dashboard/expanded-app/logs/LogFilterContainer.tsx
  32. 147 201
      dashboard/src/main/home/app-dashboard/expanded-app/logs/LogSection.tsx
  33. 161 0
      dashboard/src/main/home/app-dashboard/expanded-app/logs/StyledLogs.tsx
  34. 43 1
      dashboard/src/main/home/app-dashboard/expanded-app/logs/types.ts
  35. 108 20
      dashboard/src/main/home/app-dashboard/expanded-app/logs/utils.ts
  36. 4 2
      dashboard/src/shared/api.tsx
  37. 0 31
      dashboard/src/shared/types.tsx
  38. 7 5
      internal/analytics/tracks.go
  39. 56 27
      internal/integrations/ci/actions/stack.go

+ 8 - 0
api/server/handlers/cluster/detect_agent_installed.go

@@ -54,6 +54,7 @@ func (c *DetectAgentInstalledHandler) ServeHTTP(w http.ResponseWriter, r *http.R
 	res := &types.DetectAgentResponse{
 		Version:       getAgentVersionFromDeployment(depl),
 		ShouldUpgrade: false,
+		Image:         getImageFromDeployment(depl),
 	}
 
 	if res.Version != "v3" {
@@ -74,3 +75,10 @@ func getAgentVersionFromDeployment(depl *v1.Deployment) string {
 
 	return "v1"
 }
+
+func getImageFromDeployment(depl *v1.Deployment) string {
+	if len(depl.Spec.Template.Spec.Containers) > 0 {
+		return depl.Spec.Template.Spec.Containers[0].Image
+	}
+	return ""
+}

+ 1 - 0
api/server/handlers/porter_app/analytics.go

@@ -87,6 +87,7 @@ func (v *PorterAppAnalyticsHandler) ServeHTTP(w http.ResponseWriter, r *http.Req
 			FirstName:              user.FirstName,
 			LastName:               user.LastName,
 			CompanyName:            user.CompanyName,
+			DeleteWorkflowFile:     request.DeleteWorkflowFile,
 		}))
 	}
 

+ 6 - 6
api/server/handlers/porter_app/create.go

@@ -298,15 +298,15 @@ func (c *CreatePorterAppHandler) ServeHTTP(w http.ResponseWriter, r *http.Reques
 	} else {
 		telemetry.WithAttributes(span, telemetry.AttributeKV{Key: "upgrading-application", Value: true})
 
-		// create/update the release job chart
+		// create/update the pre-deploy job chart
 		if request.OverrideRelease {
 			if preDeployJobValues == nil {
-				releaseJobName := fmt.Sprintf("%s-r", stackName)
-				_, err := helmAgent.GetRelease(ctx, releaseJobName, 0, false)
+				preDeployJobName := fmt.Sprintf("%s-r", stackName)
+				_, err := helmAgent.GetRelease(ctx, preDeployJobName, 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)
+					_, err = helmAgent.UninstallChart(ctx, preDeployJobName)
 					if err != nil {
 						err = telemetry.Error(ctx, span, err, "error uninstalling pre-deploy job chart")
 						c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
@@ -314,8 +314,8 @@ func (c *CreatePorterAppHandler) ServeHTTP(w http.ResponseWriter, r *http.Reques
 					}
 				}
 			} else {
-				releaseJobName := fmt.Sprintf("%s-r", stackName)
-				helmRelease, err := helmAgent.GetRelease(ctx, releaseJobName, 0, false)
+				preDeployJobName := fmt.Sprintf("%s-r", stackName)
+				helmRelease, err := helmAgent.GetRelease(ctx, preDeployJobName, 0, false)
 				if err != nil {
 					telemetry.WithAttributes(span, telemetry.AttributeKV{Key: "creating-pre-deploy-job", Value: true})
 					conf, err := createReleaseJobChart(

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

@@ -123,6 +123,9 @@ func (p *CreateUpdatePorterAppEventHandler) createNewAppEvent(ctx context.Contex
 	if err != nil {
 		return types.PorterAppEvent{}, telemetry.Error(ctx, span, err, "error retrieving porter app by name for cluster")
 	}
+	if app == nil || app.ID == 0 {
+		return types.PorterAppEvent{}, telemetry.Error(ctx, span, nil, "porter app not found")
+	}
 	telemetry.WithAttributes(span,
 		telemetry.AttributeKV{Key: "porter-app-id", Value: app.ID},
 		telemetry.AttributeKV{Key: "porter-app-name", Value: porterAppName},

+ 64 - 74
api/server/handlers/porter_app/create_secret_and_open_pr.go

@@ -17,7 +17,6 @@ import (
 	"github.com/porter-dev/porter/internal/auth/token"
 	"github.com/porter-dev/porter/internal/integrations/ci/actions"
 	"github.com/porter-dev/porter/internal/models"
-	"github.com/porter-dev/porter/internal/telemetry"
 )
 
 type OpenStackPRHandler struct {
@@ -35,83 +34,76 @@ func NewOpenStackPRHandler(
 }
 
 func (c *OpenStackPRHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
-	ctx, span := telemetry.NewSpan(r.Context(), "serve-create-secret-and-open-pr")
-	defer span.End()
-
-	user, _ := ctx.Value(types.UserScope).(*models.User)
-	project, _ := ctx.Value(types.ProjectScope).(*models.Project)
-	cluster, _ := ctx.Value(types.ClusterScope).(*models.Cluster)
-
+	user, _ := r.Context().Value(types.UserScope).(*models.User)
+	project, _ := r.Context().Value(types.ProjectScope).(*models.Project)
+	cluster, _ := r.Context().Value(types.ClusterScope).(*models.Cluster)
 	stackName, reqErr := requestutils.GetURLParamString(r, types.URLParamStackName)
 	if reqErr != nil {
 		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(reqErr, http.StatusBadRequest))
 		return
 	}
-	telemetry.WithAttributes(span,
-		telemetry.AttributeKV{Key: "stack-name", Value: stackName},
-	)
 
 	request := &types.CreateSecretAndOpenGHPRRequest{}
 	if ok := c.DecodeAndValidate(w, r, request); !ok {
 		return
 	}
 
-	telemetry.WithAttributes(span,
-		telemetry.AttributeKV{Key: "repo-owner", Value: request.GithubRepoOwner},
-		telemetry.AttributeKV{Key: "repo-name", Value: request.GithubRepoName},
-		telemetry.AttributeKV{Key: "branch", Value: request.Branch},
-		telemetry.AttributeKV{Key: "open-pr", Value: request.OpenPr},
-	)
-
 	client, err := getGithubClient(c.Config(), request.GithubAppInstallationID)
 	if err != nil {
 		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
 		return
 	}
 
-	// generate porter jwt token
-	jwt, err := token.GetTokenForAPI(user.ID, project.ID)
-	if err != nil {
-		err = telemetry.Error(ctx, span, err, "Error getting token for API")
-		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
-		return
-	}
-	encoded, err := jwt.EncodeToken(c.Config().TokenConf)
-	if err != nil {
-		err = telemetry.Error(ctx, span, err, "Error encoding token")
-		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
-		return
-	}
+	var secretName string
+	if request.DeleteWorkflowFilename == "" {
+		// generate porter jwt token
+		jwt, err := token.GetTokenForAPI(user.ID, project.ID)
+		if err != nil {
+			c.HandleAPIError(w, r, apierrors.NewErrInternal(fmt.Errorf("error getting token for API: %w", err)))
+			return
+		}
+		encoded, err := jwt.EncodeToken(c.Config().TokenConf)
+		if err != nil {
+			c.HandleAPIError(w, r, apierrors.NewErrInternal(fmt.Errorf("error encoding API token: %w", err)))
+			return
+		}
 
-	// create porter secret
-	secretName := fmt.Sprintf("PORTER_STACK_%d_%d", project.ID, cluster.ID)
-	err = actions.CreateGithubSecret(
-		client,
-		secretName,
-		encoded,
-		request.GithubRepoOwner,
-		request.GithubRepoName,
-	)
-	if err != nil {
-		err = telemetry.Error(ctx, span, err, "Error creating github secret")
-		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
-		return
+		// create porter secret
+		secretName = fmt.Sprintf("PORTER_STACK_%d_%d", project.ID, cluster.ID)
+		err = actions.CreateGithubSecret(
+			client,
+			secretName,
+			encoded,
+			request.GithubRepoOwner,
+			request.GithubRepoName,
+		)
+		if err != nil {
+			c.HandleAPIError(w, r, apierrors.NewErrInternal(fmt.Errorf("error generating secret: %w", err)))
+			return
+		}
 	}
 
 	var pr *github.PullRequest
-	if request.OpenPr {
+	var prRequestBody string
+	if request.DeleteWorkflowFilename == "" {
+		prRequestBody = "Hello 👋 from Porter! Please merge this PR to finish setting up your application."
+	} else {
+		prRequestBody = "Please merge this PR to delete the workflow file associated with your application."
+	}
+	if request.OpenPr || request.DeleteWorkflowFilename != "" {
 		pr, err = actions.OpenGithubPR(&actions.GithubPROpts{
-			Client:         client,
-			GitRepoOwner:   request.GithubRepoOwner,
-			GitRepoName:    request.GithubRepoName,
-			StackName:      stackName,
-			ProjectID:      project.ID,
-			ClusterID:      cluster.ID,
-			ServerURL:      c.Config().ServerConf.ServerURL,
-			DefaultBranch:  request.Branch,
-			SecretName:     secretName,
-			PorterYamlPath: request.PorterYamlPath,
-			Body:           "Hello 👋 from Porter! Please merge this PR to finish setting up your application.",
+			Client:                 client,
+			GitRepoOwner:           request.GithubRepoOwner,
+			GitRepoName:            request.GithubRepoName,
+			StackName:              stackName,
+			ProjectID:              project.ID,
+			ClusterID:              cluster.ID,
+			ServerURL:              c.Config().ServerConf.ServerURL,
+			DefaultBranch:          request.Branch,
+			SecretName:             secretName,
+			PorterYamlPath:         request.PorterYamlPath,
+			Body:                   prRequestBody,
+			DeleteWorkflowFilename: request.DeleteWorkflowFilename,
 		})
 	}
 
@@ -120,15 +112,13 @@ func (c *OpenStackPRHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
 
 		if unwrappedErr != nil {
 			if errors.Is(unwrappedErr, actions.ErrProtectedBranch) {
-				err = telemetry.Error(ctx, span, err, "Branch is protected")
 				c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusConflict))
 			} else if errors.Is(unwrappedErr, actions.ErrCreatePRForProtectedBranch) {
-				err = telemetry.Error(ctx, span, err, "Error creating PR for protected branch")
 				c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusPreconditionFailed))
 			}
 		} else {
-			err = telemetry.Error(ctx, span, err, "Error opening PR")
-			c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
+			c.HandleAPIError(w, r, apierrors.NewErrInternal(fmt.Errorf("error setting up application in the github "+
+				"repo: %w", err)))
 			return
 		}
 	}
@@ -139,21 +129,21 @@ func (c *OpenStackPRHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
 			URL: pr.GetHTMLURL(),
 		}
 
-		// update DB with the PR url
-		porterApp, err := c.Repo().PorterApp().ReadPorterAppByName(cluster.ID, stackName, 0)
-		if err != nil {
-			err = telemetry.Error(ctx, span, err, "Error reading porter app from db")
-			c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
-			return
-		}
+		if request.DeleteWorkflowFilename == "" {
+			// update DB with the PR url
+			porterApp, err := c.Repo().PorterApp().ReadPorterAppByName(cluster.ID, stackName, 0)
+			if err != nil {
+				c.HandleAPIError(w, r, apierrors.NewErrInternal(fmt.Errorf("unable to get porter app db: %w", err)))
+				return
+			}
 
-		porterApp.PullRequestURL = pr.GetHTMLURL()
+			porterApp.PullRequestURL = pr.GetHTMLURL()
 
-		_, err = c.Repo().PorterApp().UpdatePorterApp(porterApp)
-		if err != nil {
-			err = telemetry.Error(ctx, span, err, "Unable to write pr url to porter app to db")
-			c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
-			return
+			_, err = c.Repo().PorterApp().UpdatePorterApp(porterApp)
+			if err != nil {
+				c.HandleAPIError(w, r, apierrors.NewErrInternal(fmt.Errorf("unable to write pr url to porter app db: %w", err)))
+				return
+			}
 		}
 	}
 
@@ -180,4 +170,4 @@ func getGithubClient(config *config.Config, gitInstallationId int64) (*github.Cl
 	}
 
 	return github.NewClient(&http.Client{Transport: itr}), nil
-}
+}

+ 71 - 6
api/server/handlers/porter_app/rollback.go

@@ -3,6 +3,7 @@ package porter_app
 import (
 	"fmt"
 	"net/http"
+	"strings"
 
 	"github.com/porter-dev/porter/api/server/authz"
 	"github.com/porter-dev/porter/api/server/handlers"
@@ -11,8 +12,10 @@ import (
 	"github.com/porter-dev/porter/api/server/shared/config"
 	"github.com/porter-dev/porter/api/server/shared/requestutils"
 	"github.com/porter-dev/porter/api/types"
+	"github.com/porter-dev/porter/internal/helm"
 	"github.com/porter-dev/porter/internal/models"
 	"github.com/porter-dev/porter/internal/telemetry"
+	"gopkg.in/yaml.v2"
 )
 
 type RollbackPorterAppHandler struct {
@@ -59,14 +62,35 @@ func (c *RollbackPorterAppHandler) ServeHTTP(w http.ResponseWriter, r *http.Requ
 		return
 	}
 
-	helmRelease, err := helmAgent.GetRelease(ctx, stackName, 0, false)
+	k8sAgent, err := c.GetAgent(r, cluster, namespace)
 	if err != nil {
-		err = telemetry.Error(ctx, span, err, "error getting helm release")
+		err = telemetry.Error(ctx, span, err, "error getting k8s agent")
 		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
 		return
 	}
 
-	imageInfo := attemptToGetImageInfoFromRelease(helmRelease.Config)
+	helmReleaseFromRequestedRevision, err := helmAgent.GetRelease(ctx, stackName, request.Revision, false)
+	if err != nil {
+		err = telemetry.Error(ctx, span, err, "error getting helm release for requested revision")
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
+		return
+	}
+
+	latestHelmRelease, err := helmAgent.GetRelease(ctx, stackName, 0, false)
+	if err != nil {
+		err = telemetry.Error(ctx, span, err, "error getting latest helm release")
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
+		return
+	}
+
+	valuesYaml, err := yaml.Marshal(helmReleaseFromRequestedRevision.Config)
+	if err != nil {
+		err = telemetry.Error(ctx, span, err, "error marshalling helm release config to yaml")
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
+		return
+	}
+
+	imageInfo := attemptToGetImageInfoFromRelease(helmReleaseFromRequestedRevision.Config)
 	if imageInfo.Tag == "" {
 		imageInfo.Tag = "latest"
 	}
@@ -77,15 +101,56 @@ func (c *RollbackPorterAppHandler) ServeHTTP(w http.ResponseWriter, r *http.Requ
 		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
 		return
 	}
+	injectLauncher := strings.Contains(porterApp.Builder, "heroku") ||
+		strings.Contains(porterApp.Builder, "paketo")
+
+	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
+	}
 
-	err = helmAgent.RollbackRelease(ctx, helmRelease.Name, request.Revision)
+	chart, values, _, err := parse(
+		ParseConf{
+			ImageInfo:    imageInfo,
+			ServerConfig: c.Config(),
+			ProjectID:    cluster.ProjectID,
+			Namespace:    namespace,
+			SubdomainCreateOpts: SubdomainCreateOpts{
+				k8sAgent:       k8sAgent,
+				dnsRepo:        c.Repo().DNSRecord(),
+				powerDnsClient: c.Config().PowerDNSClient,
+				appRootDomain:  c.Config().ServerConf.AppRootDomain,
+				stackName:      stackName,
+			},
+			InjectLauncherToStartCommand: injectLauncher,
+			FullHelmValues:               string(valuesYaml),
+		},
+	)
 	if err != nil {
-		err = telemetry.Error(ctx, span, err, "error rolling back release")
+		err = telemetry.Error(ctx, span, err, "error parsing helm chart")
 		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
 		return
 	}
 
-	_, err = createPorterAppEvent(ctx, "SUCCESS", porterApp.ID, helmRelease.Version+1, imageInfo.Tag, c.Repo().PorterAppEvent())
+	conf := &helm.InstallChartConfig{
+		Chart:      chart,
+		Name:       stackName,
+		Namespace:  namespace,
+		Values:     values,
+		Cluster:    cluster,
+		Repo:       c.Repo(),
+		Registries: registries,
+	}
+	_, 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
+	}
+
+	_, err = createPorterAppEvent(ctx, "SUCCESS", porterApp.ID, latestHelmRelease.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))

+ 1 - 0
api/types/agent.go

@@ -4,6 +4,7 @@ type DetectAgentResponse struct {
 	Version       string `json:"version"`
 	LatestVersion string `json:"latest_version"`
 	ShouldUpgrade bool   `json:"should_upgrade"`
+	Image         string `json:"image"`
 }
 
 type GetAgentStatusResponse struct {

+ 5 - 3
api/types/stack.go

@@ -18,6 +18,7 @@ type CreateSecretAndOpenGHPRRequest struct {
 	OpenPr                  bool   `json:"open_pr"`
 	Branch                  string `json:"branch"`
 	PorterYamlPath          string `json:"porter_yaml_path"`
+	DeleteWorkflowFilename  string `json:"delete_workflow_filename"`
 }
 
 type CreateSecretAndOpenGHPRResponse struct {
@@ -27,7 +28,8 @@ type CreateSecretAndOpenGHPRResponse struct {
 type GetStackResponse PorterApp
 
 type PorterAppAnalyticsRequest struct {
-	Step         string `json:"step" form:"required,max=255"`
-	StackName    string `json:"stack_name"`
-	ErrorMessage string `json:"error_message"`
+	Step               string `json:"step" form:"required,max=255"`
+	StackName          string `json:"stack_name"`
+	ErrorMessage       string `json:"error_message"`
+	DeleteWorkflowFile bool   `json:"delete_workflow_file"`
 }

+ 3 - 0
dashboard/src/assets/canceled.svg

@@ -0,0 +1,3 @@
+<svg width="12" height="12" viewBox="0 0 12 12" fill="none" xmlns="http://www.w3.org/2000/svg">
+<path d="M11 1L1 11M11 11L1 0.999998" stroke="#FFBF00" stroke-width="1" stroke-linecap="round"/>
+</svg>

+ 3 - 0
dashboard/src/assets/failure.svg

@@ -0,0 +1,3 @@
+<svg width="12" height="12" viewBox="0 0 12 12" fill="none" xmlns="http://www.w3.org/2000/svg">
+<path d="M11 1L1 11M11 11L1 0.999998" stroke="#FF6060" stroke-width="1" stroke-linecap="round"/>
+</svg>

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

@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" shape-rendering="geometricPrecision" text-rendering="geometricPrecision" image-rendering="optimizeQuality" fill-rule="evenodd" clip-rule="evenodd" viewBox="0 0 512 410.73"><path fill-rule="nonzero" d="M335.62 410.73H164.96V239.89L13.31 59.96C7.33 52.52 3.19 44.79 1.29 37.65c-1.79-6.72-1.76-13.28.34-19.1 2.3-6.44 6.92-11.63 13.91-14.9C20.35 1.41 26.3.13 33.4.1L472.7.04c7.93-.29 14.95.96 20.74 3.44 7.02 2.97 12.28 7.87 15.44 14.17 3.05 6.1 3.93 13.27 2.34 21.06-1.5 7.24-5.17 15.11-11.32 23.16l-151.94 178.1v170.76h-12.34zm95.61-347.71-69.16 81.05-18.67-16.01 69.16-81.05 18.67 16.01zm-84.8 99.39-24.45 28.66-18.68-16.01 24.45-28.66 18.68 16.01zM189.64 386.06h133.64V235.48l3-8L480.45 46.79c3.77-4.97 5.94-9.39 6.7-13.04.45-2.2.35-3.95-.24-5.12-.49-.97-1.58-1.87-3.19-2.55-2.53-1.13-6.06-1.64-10.44-1.42l-439.84.06c-3.33-.05-5.83.41-7.5 1.18-.68.32-1.09.65-1.18.92-.32.91-.2 2.48.33 4.46 1.05 3.96 3.61 8.57 7.38 13.28L186.7 227.59l2.94 7.89v150.58z"/></svg>

BIN=BIN
dashboard/src/assets/filter-outline-new.png


+ 0 - 3
dashboard/src/main/home/Home.tsx

@@ -407,9 +407,6 @@ const Home: React.FC<Props> = (props) => {
             <Route path="/apps/new/app">
               <NewAppFlow />
             </Route>
-            <Route path="/apps/:appName/events/:eventId">
-              <ExpandedApp />
-            </Route>
             <Route path="/apps/:appName/:tab">
               <ExpandedApp />
             </Route>

+ 67 - 0
dashboard/src/main/home/app-dashboard/expanded-app/DeleteApplicationModal.tsx

@@ -0,0 +1,67 @@
+import Button from "components/porter/Button";
+import Checkbox from "components/porter/Checkbox";
+import Modal from "components/porter/Modal";
+import Spacer from "components/porter/Spacer";
+import Text from "components/porter/Text";
+
+import React, { useState } from "react";
+import styled from "styled-components";
+
+type Props = {
+    closeModal: () => void;
+    githubWorkflowFilename: string;
+    deleteApplication: (deleteWorkflowFile?: boolean) => void;
+};
+
+const GithubActionModal: React.FC<Props> = ({
+    closeModal,
+    githubWorkflowFilename,
+    deleteApplication,
+}) => {
+    const [deleteGithubWorkflow, setDeleteGithubWorkflow] = useState(true);
+
+    const renderDeleteGithubWorkflowText = () => {
+        if (githubWorkflowFilename === "") {
+            return null;
+        }
+        return (
+            <>
+                <Text color="helper">You may also want to remove this application's associated CI file from your repository.</Text>
+                <Spacer y={0.5} />
+                <Checkbox
+                    checked={deleteGithubWorkflow}
+                    toggleChecked={() => setDeleteGithubWorkflow(!deleteGithubWorkflow)}
+                >
+                    <Text color="helper">
+                        Upon deletion, open a PR to remove this application's associated CI file (<Code>{githubWorkflowFilename}</Code>) from my repository.
+                    </Text>
+                </Checkbox>
+                <Spacer y={1} />
+            </>
+        )
+    }
+
+    return (
+        <Modal closeModal={closeModal}>
+            <Text size={16}>
+                Confirm deletion
+            </Text>
+            <Spacer y={0.5} />
+            <Text color="helper">Click the button below to confirm deletion. This action is irreversible.</Text>
+            <Spacer y={0.5} />
+            {renderDeleteGithubWorkflowText()}
+            <Button
+                onClick={() => deleteApplication(deleteGithubWorkflow)}
+                color="#b91133"
+            >
+                Delete
+            </Button>
+        </Modal>
+    );
+};
+
+export default GithubActionModal;
+
+const Code = styled.span`
+  font-family: monospace;
+`;

+ 93 - 69
dashboard/src/main/home/app-dashboard/expanded-app/ExpandedApp.tsx

@@ -1,5 +1,5 @@
 import React, { useEffect, useState, useContext } from "react";
-import { RouteComponentProps, useParams, withRouter } from "react-router";
+import { RouteComponentProps, useLocation, useParams, withRouter } from "react-router";
 import styled from "styled-components";
 import yaml from "js-yaml";
 
@@ -26,12 +26,10 @@ import Back from "components/porter/Back";
 import TabSelector from "components/TabSelector";
 import Icon from "components/porter/Icon";
 import { ChartType, CreateUpdatePorterAppOptions } from "shared/types";
-import RevisionSection from "main/home/cluster-dashboard/expanded-chart/RevisionSection";
 import BuildSettingsTab from "../build-settings/BuildSettingsTab";
 import Button from "components/porter/Button";
 import Services from "../new-app-flow/Services";
 import { Service } from "../new-app-flow/serviceTypes";
-import ConfirmOverlay from "components/porter/ConfirmOverlay";
 import Fieldset from "components/porter/Fieldset";
 import { PorterJson, createFinalPorterYaml } from "../new-app-flow/schema";
 import { KeyValueType } from "main/home/cluster-dashboard/env-groups/EnvGroupArray";
@@ -47,8 +45,10 @@ import _ from "lodash";
 import AnimateHeight from "react-animate-height";
 import { PartialEnvGroup, PopulatedEnvGroup } from "../../../../components/porter-form/types";
 import { BuildMethod, PorterApp } from "../types/porterApp";
+import EventFocusView from "./activity-feed/events/focus-views/EventFocusView";
 import HelmValuesTab from "./HelmValuesTab";
-import ProjectDeleteConsent from "main/home/project-settings/ProjectDeleteConsent";
+import SettingsTab from "./SettingsTab";
+import PorterAppRevisionSection from "./PorterAppRevisionSection";
 
 type Props = RouteComponentProps & {};
 
@@ -62,6 +62,7 @@ const icons = [
 
 const validTabs = [
   "activity",
+  "events",
   "overview",
   "logs",
   "metrics",
@@ -91,6 +92,7 @@ const ExpandedApp: React.FC<Props> = ({ ...props }) => {
   const [workflowCheckPassed, setWorkflowCheckPassed] = useState<boolean>(
     false
   );
+  const [githubWorkflowFilename, setGithubWorkflowFilename] = useState<string>("");
   const [hasBuiltImage, setHasBuiltImage] = useState<boolean>(false);
 
   const [forceRefreshRevisions, setForceRefreshRevisions] = useState<boolean>(
@@ -98,7 +100,6 @@ const ExpandedApp: React.FC<Props> = ({ ...props }) => {
   );
 
   const [showRevisions, setShowRevisions] = useState<boolean>(false);
-  const [showDeleteOverlay, setShowDeleteOverlay] = useState<boolean>(false);
 
   // this is what we read from their porter.yaml in github
   const [porterJson, setPorterJson] = useState<PorterJson | undefined>(undefined);
@@ -118,7 +119,15 @@ const ExpandedApp: React.FC<Props> = ({ ...props }) => {
   const [tempPorterApp, setTempPorterApp] = useState<PorterApp>();
   const [buildView, setBuildView] = useState<BuildMethod>("docker");
 
-  const { eventId, tab } = useParams<Params>();
+  const { tab } = useParams<Params>();
+  const { search } = useLocation();
+  const queryParams = new URLSearchParams(search);
+  const logFilterQueryParamOpts = {
+    revision: queryParams.get('version'),
+    output_stream: queryParams.get('output_stream'),
+    service: queryParams.get('service'),
+  }
+  const eventId = queryParams.get('event_id');
   const selectedTab: ValidTab = tab != null && validTabs.includes(tab) ? tab : DEFAULT_TAB;
 
   useEffect(() => {
@@ -165,7 +174,6 @@ const ExpandedApp: React.FC<Props> = ({ ...props }) => {
           revision: revision,
         }
       );
-
       let preDeployChartData;
       // get the pre-deploy chart
       try {
@@ -184,7 +192,6 @@ const ExpandedApp: React.FC<Props> = ({ ...props }) => {
       } catch (err) {
         // that's ok if there's an error, just means there is no pre-deploy chart
       }
-
       // update apps and release
       const newAppData = {
         app: resPorterApp?.data,
@@ -207,7 +214,6 @@ const ExpandedApp: React.FC<Props> = ({ ...props }) => {
           }
         )
         .then((res) => res.data);
-
       const populateEnvGroupsPromises = envGroups?.map((envGroup) =>
         api
           .getEnvGroup<PopulatedEnvGroup>(
@@ -223,11 +229,8 @@ const ExpandedApp: React.FC<Props> = ({ ...props }) => {
           )
           .then((res) => res.data)
       );
-
       const populatedEnvGroups = await Promise.all(populateEnvGroupsPromises);
-
       const filteredEnvGroups = populatedEnvGroups.filter(envGroup => envGroup.applications.includes(newAppData.chart.name));
-
       setSyncedEnvGroups(filteredEnvGroups)
       setPorterJson(porterJson);
       setAppData(newAppData);
@@ -249,7 +252,6 @@ const ExpandedApp: React.FC<Props> = ({ ...props }) => {
         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;
@@ -273,12 +275,13 @@ const ExpandedApp: React.FC<Props> = ({ ...props }) => {
             }
           );
           setWorkflowCheckPassed(true);
+          setGithubWorkflowFilename(`porter_stack_${resPorterApp.data.name}.yml`);
         } catch (err) {
           // Handle unmerged PR
           if (err.response?.status === 404) {
             try {
               // Check for user-copied porter.yml as fallback
-              const resPorterYml = await api.getBranchContents(
+              await api.getBranchContents(
                 "<token>",
                 { dir: `./.github/workflows/porter.yml` },
                 {
@@ -291,6 +294,7 @@ const ExpandedApp: React.FC<Props> = ({ ...props }) => {
                 }
               );
               setWorkflowCheckPassed(true);
+              setGithubWorkflowFilename(`porter.yml`);
             } catch (err) {
               setWorkflowCheckPassed(false);
             }
@@ -304,8 +308,7 @@ const ExpandedApp: React.FC<Props> = ({ ...props }) => {
     }
   };
 
-  const deletePorterApp = async () => {
-    setShowDeleteOverlay(false);
+  const deletePorterApp = async (deleteGHWorkflowFile?: boolean) => {
     setDeleting(true);
     const { appName } = props.match.params as any;
     if (syncedEnvGroups) {
@@ -323,7 +326,6 @@ const ExpandedApp: React.FC<Props> = ({ ...props }) => {
           }
         );
       });
-
       try {
         await Promise.all(removeApplicationToEnvGroupPromises);
       } catch (error) {
@@ -340,6 +342,10 @@ const ExpandedApp: React.FC<Props> = ({ ...props }) => {
           name: appName,
         }
       );
+    } catch (err) {
+      // TODO: handle error
+    }
+    try {
       await api.deleteNamespace(
         "<token>",
         {},
@@ -349,24 +355,53 @@ const ExpandedApp: React.FC<Props> = ({ ...props }) => {
           namespace: `porter-stack-${appName}`,
         }
       );
-      // intentionally do not await this promise
-      api.updateStackStep(
-        "<token>",
-        {
-          step: "stack-deletion",
-          stack_name: appName,
-        },
-        {
-          project_id: currentProject.id,
-          cluster_id: currentCluster.id,
-        }
-      );
-      props.history.push("/apps");
     } catch (err) {
       // TODO: handle error
-    } finally {
-      setDeleting(false);
     }
+
+    let deleteWorkflowFile = false;
+
+    if (deleteGHWorkflowFile && githubWorkflowFilename !== "" && appData?.app != null) {
+      try {
+        const res = await api.createSecretAndOpenGitHubPullRequest(
+          "<token>",
+          {
+            github_app_installation_id: appData.app.git_repo_id,
+            github_repo_owner: appData.app.repo_name.split("/")[0],
+            github_repo_name: appData.app.repo_name.split("/")[1],
+            branch: appData.app.git_branch,
+            delete_workflow_filename: githubWorkflowFilename,
+          },
+          {
+            project_id: currentProject.id,
+            cluster_id: currentCluster.id,
+            stack_name: appData.app.name,
+          }
+        );
+        if (res.data?.url) {
+          window.open(res.data.url, "_blank", "noreferrer");
+        }
+        deleteWorkflowFile = true;
+      } catch (err) {
+        // TODO: handle error
+      }
+    }
+
+    // intentionally do not await this promise
+    api.updateStackStep(
+      "<token>",
+      {
+        step: "stack-deletion",
+        stack_name: appName,
+        delete_workflow_file: deleteWorkflowFile,
+      },
+      {
+        project_id: currentProject.id,
+        cluster_id: currentCluster.id,
+      }
+    );
+
+    props.history.push("/apps");
   };
 
   const updatePorterApp = async (options: Partial<CreateUpdatePorterAppOptions>) => {
@@ -623,7 +658,18 @@ const ExpandedApp: React.FC<Props> = ({ ...props }) => {
           chart={appData.chart}
           stackName={appData?.app?.name}
           appData={appData}
-          eventId={eventId}
+        />;
+      case "events":
+        if (eventId != null && eventId !== "") {
+          return <EventFocusView
+            eventId={eventId}
+            appData={appData}
+          />;
+        }
+        return <ActivityFeed
+          chart={appData.chart}
+          stackName={appData?.app?.name}
+          appData={appData}
         />;
       case "overview":
         return (
@@ -705,26 +751,18 @@ const ExpandedApp: React.FC<Props> = ({ ...props }) => {
           />
         );
       case "settings":
-        return (
-          <>
-            <Text size={16}>Delete "{appData.app.name}"</Text>
-            <Spacer y={1} />
-            <Text color="helper">
-              Delete this application and all of its resources.
-            </Text>
-            <Spacer y={1} />
-            <Button
-              onClick={() => {
-                setShowDeleteOverlay(true);
-              }}
-              color="#b91133"
-            >
-              Delete
-            </Button>
-          </>
-        );
+        return <SettingsTab
+          appName={appData.app.name}
+          githubWorkflowFilename={githubWorkflowFilename}
+          deleteApplication={deletePorterApp}
+        />;
       case "logs":
-        return <LogSection currentChart={appData.chart} services={services} />;
+        return <LogSection
+          currentChart={appData.chart}
+          services={services.filter(Service.isNonRelease)}
+          appName={appData.app.name}
+          filterOpts={logFilterQueryParamOpts}
+        />;
       case "metrics":
         return <MetricsSection currentChart={appData.chart} />;
       case "debug":
@@ -758,7 +796,6 @@ const ExpandedApp: React.FC<Props> = ({ ...props }) => {
           chart={appData.chart}
           stackName={appData?.app?.name}
           appData={appData}
-          eventId={eventId}
         />;
     }
   };
@@ -913,7 +950,7 @@ const ExpandedApp: React.FC<Props> = ({ ...props }) => {
               ) : (
                 <>
                   <DarkMatter />
-                  <RevisionSection
+                  <PorterAppRevisionSection
                     showRevisions={showRevisions}
                     toggleShowRevisions={() => {
                       setShowRevisions(!showRevisions);
@@ -927,7 +964,9 @@ const ExpandedApp: React.FC<Props> = ({ ...props }) => {
                       appData.chart.latest_version !==
                       appData.chart.chart.metadata.version
                     }
+                    updatePorterApp={updatePorterApp}
                     latestVersion={appData.chart.latest_version}
+                    appName={appData.app.name}
                   />
                   <DarkMatter antiHeight="-18px" />
                 </>
@@ -989,17 +1028,6 @@ const ExpandedApp: React.FC<Props> = ({ ...props }) => {
           )}
         </StyledExpandedApp>
       )}
-      {showDeleteOverlay && (
-        <ConfirmOverlay
-          message={`Are you sure you want to delete "${appData.app.name}"?`}
-          onYes={() => {
-            deletePorterApp();
-          }}
-          onNo={() => {
-            setShowDeleteOverlay(false);
-          }}
-        />
-      )}
     </>
   );
 };
@@ -1011,10 +1039,6 @@ const A = styled.a`
   align-items: center;
 `;
 
-const Underline = styled.div`
-  border-bottom: 1px solid #ffffff;
-`;
-
 const RefreshButton = styled.div`
   color: #ffffff;
   display: flex;

+ 547 - 0
dashboard/src/main/home/app-dashboard/expanded-app/PorterAppRevisionSection.tsx

@@ -0,0 +1,547 @@
+import React, { Component } from "react";
+import styled from "styled-components";
+import loading from "assets/loading.gif";
+
+import api from "shared/api";
+import { Context } from "shared/Context";
+import { ChartType, CreateUpdatePorterAppOptions, StorageType } from "shared/types";
+
+import ConfirmOverlay from "components/ConfirmOverlay";
+import { withAuth, WithAuthProps } from "shared/auth/AuthorizationHoc";
+
+import Modal from "main/home/modals/Modal";
+import UpgradeChartModal from "main/home/modals/UpgradeChartModal";
+import { readableDate } from "shared/string_utils";
+import { createPortal } from "react-dom";
+import yaml from "js-yaml";
+
+type PropsType = WithAuthProps & {
+    chart: ChartType;
+    refreshChart: () => void;
+    setRevision: (x: ChartType, isCurrent?: boolean) => void;
+    forceRefreshRevisions: boolean;
+    refreshRevisionsOff: () => void;
+    shouldUpdate: boolean;
+    upgradeVersion: (version: string, cb: () => void) => void;
+    latestVersion: string;
+    showRevisions?: boolean;
+    toggleShowRevisions?: () => void;
+    updatePorterApp: (options: Partial<CreateUpdatePorterAppOptions>) => Promise<void>;
+    appName: string;
+};
+
+type StateType = {
+    revisions: ChartType[];
+    rollbackRevision: number | null;
+    upgradeVersion: string;
+    loading: boolean;
+    maxVersion: number;
+    expandRevisions: boolean;
+};
+
+// TODO: refactor this component it's so gross
+class PorterAppRevisionSection extends Component<PropsType, StateType> {
+    state = {
+        revisions: [] as ChartType[],
+        rollbackRevision: null as number | null,
+        upgradeVersion: "",
+        loading: false,
+        maxVersion: 0, // Track most recent version even when previewing old revisions
+        expandRevisions: false,
+    };
+
+    ws: WebSocket | null = null;
+
+    refreshHistory = () => {
+        let { chart } = this.props;
+        let { currentCluster, currentProject } = this.context;
+
+        return api
+            .getRevisions(
+                "<token>",
+                {},
+                {
+                    id: currentProject.id,
+                    namespace: chart.namespace,
+                    cluster_id: currentCluster.id,
+                    name: chart.name,
+                }
+            )
+            .then((res) => {
+                res.data.sort((a: ChartType, b: ChartType) => {
+                    return -(a.version - b.version);
+                });
+                this.setState({
+                    revisions: res.data,
+                    maxVersion: res.data[0].version,
+                });
+            })
+            .catch(console.log);
+    };
+
+    componentDidMount() {
+        this.refreshHistory();
+        this.connectToLiveUpdates();
+    }
+
+    componentWillUnmount() {
+        if (this.ws) {
+            this.ws.close(); // Close the WebSocket connection
+        }
+    }
+
+    connectToLiveUpdates() {
+        let { chart } = this.props;
+        let { currentCluster, currentProject } = this.context;
+
+        const apiPath = `/api/projects/${currentProject.id}/clusters/${currentCluster.id}/helm_release?charts=${chart.name}`;
+        const protocol = window.location.protocol == "https:" ? "wss" : "ws";
+        const url = `${protocol}://${window.location.host}`;
+
+        this.ws = new WebSocket(`${url}${apiPath}`);
+
+        this.ws.onopen = () => {
+            console.log("connected to chart live updates websocket");
+        };
+
+        this.ws.onmessage = (evt: MessageEvent) => {
+            let event = JSON.parse(evt.data);
+
+            if (event.event_type == "UPDATE") {
+                let object = event.Object;
+
+                this.setState(
+                    (prevState) => {
+                        const { revisions: oldRevisions } = prevState;
+                        // Copy old array to clean up references
+                        const prevRevisions = [...oldRevisions];
+
+                        // Check if it's an update of a revision or if it's a new one
+                        const revisionIndex = prevRevisions.findIndex((rev) => {
+                            if (rev.version === object.version) {
+                                return true;
+                            }
+                        });
+
+                        // Place new one at top of the array or update the old one
+                        if (revisionIndex > -1) {
+                            prevRevisions.splice(revisionIndex, 1, object);
+                        } else {
+                            return { ...prevState, revisions: [object, ...prevRevisions] };
+                        }
+
+                        return { ...prevState, revisions: prevRevisions, maxVersion: Math.max(...prevRevisions.map(rev => rev.version)) };
+                    },
+                    () => {
+                        this.props.setRevision(this.state.revisions[0], true);
+                    }
+                );
+            }
+        };
+
+        this.ws.onclose = () => {
+            console.log("closing chart live updates websocket");
+        };
+
+        this.ws.onerror = (err: ErrorEvent) => {
+            console.log(err);
+            this.ws.close();
+        };
+    }
+
+    // Handle update of values.yaml
+    componentDidUpdate(prevProps: PropsType) {
+        if (this.props.forceRefreshRevisions) {
+            this.props.refreshRevisionsOff();
+
+            // Force refresh occurs on submit -> set current to newest
+            this.refreshHistory().then(() => {
+                this.props.setRevision(this.state.revisions[0], true);
+            });
+        } else if (this.props.chart !== prevProps.chart) {
+            this.refreshHistory();
+        }
+    }
+
+    handleRollback = async () => {
+        let { setCurrentError, currentCluster, currentProject } = this.context;
+
+        let revisionNumber = this.state.rollbackRevision;
+        if (revisionNumber == null) {
+            return;
+        }
+        this.setState({ loading: true, rollbackRevision: null });
+
+        try {
+            await api.rollbackPorterApp(
+                "<token>",
+                {
+                    revision: revisionNumber,
+                },
+                {
+                    project_id: currentProject.id,
+                    cluster_id: currentCluster.id,
+                    stack_name: this.props.appName,
+                }
+            );
+        } catch {
+            // TODO: handle error better
+            setCurrentError(err.response.data);
+        } finally {
+            this.setState({ loading: false });
+        }
+    };
+
+    handleClickRevision = (revision: ChartType) => {
+        this.props.setRevision(
+            revision,
+            revision.version === this.state.maxVersion
+        );
+    };
+
+    renderRevisionList = () => {
+        return this.state.revisions.map((revision: ChartType, i: number) => {
+            let isCurrent = revision.version === this.state.maxVersion;
+            const isGithubApp = !!this.props.chart.git_action_config;
+            const imageTag = revision.config?.image?.tag || revision.config?.global?.image?.tag;
+
+            const parsedImageTag = isGithubApp
+                ? String(imageTag).slice(0, 7)
+                : imageTag;
+
+            const isStack = !!this.props.chart.stack_id;
+
+            return (
+                <Tr
+                    key={i}
+                    onClick={() => this.handleClickRevision(revision)}
+                    selected={this.props.chart.version === revision.version}
+                >
+                    <Td>{revision.version}</Td>
+                    <Td>{readableDate(revision.info.last_deployed)}</Td>
+                    <Td>
+                        {!imageTag ? (
+                            "N/A"
+                        ) : isGithubApp && /^[0-9A-Fa-f]{7}$/g.test(imageTag) ? (
+                            <A
+                                href={`https://github.com/${this.props.chart.git_action_config?.git_repo}/commit/${imageTag}`}
+                                target="_blank"
+                                onClick={(e) => {
+                                    e.stopPropagation();
+                                }}
+                            >
+                                {parsedImageTag}
+                            </A>
+                        ) : (
+                            parsedImageTag
+                        )}
+                    </Td>
+                    <Td>v{revision.chart.metadata.version}</Td>
+                    <Td>
+                        <RollbackButton
+                            disabled={
+                                isCurrent ||
+                                !this.props.isAuthorized("application", "", [
+                                    "get",
+                                    "update",
+                                ]) ||
+                                isStack
+                            }
+                            onClick={(e) => {
+                                e.stopPropagation();
+                                this.setState({ rollbackRevision: revision.version })
+                            }
+                            }
+                        >
+                            {isCurrent ? "Current" : "Revert"}
+                        </RollbackButton>
+                    </Td>
+                </Tr >
+            );
+        });
+    };
+
+    renderExpanded = () => {
+        if (this.state.expandRevisions) {
+            return (
+                <TableWrapper>
+                    <RevisionsTable>
+                        <tbody>
+                            <Tr disableHover={true}>
+                                <Th>Revision no.</Th>
+                                <Th>Timestamp</Th>
+                                <Th>
+                                    {this.props.chart.git_action_config ? "Commit" : "Image Tag"}
+                                </Th>
+                                <Th>Template version</Th>
+                                <Th>Rollback</Th>
+                            </Tr>
+                            {this.renderRevisionList()}
+                        </tbody>
+                    </RevisionsTable>
+                </TableWrapper>
+            );
+        }
+    };
+
+    renderContents = () => {
+        if (this.state.loading) {
+            return (
+                <LoadingPlaceholder>
+                    <StatusWrapper>
+                        <LoadingGif src={loading} revision={false} /> Updating . . .
+                    </StatusWrapper>
+                </LoadingPlaceholder>
+            );
+        }
+
+        let isCurrent =
+            this.props.chart.version === this.state.maxVersion ||
+            this.state.maxVersion === 0;
+        return (
+            <div>
+                {this.state.upgradeVersion && (
+                    <Modal
+                        onRequestClose={() => this.setState({ upgradeVersion: "" })}
+                        width="500px"
+                        height="450px"
+                    >
+                        <UpgradeChartModal
+                            currentChart={this.props.chart}
+                            closeModal={() => {
+                                this.setState({ upgradeVersion: "" });
+                            }}
+                            onSubmit={() => {
+                                this.props.upgradeVersion(this.state.upgradeVersion, () => {
+                                    this.setState({ loading: false });
+                                });
+                                this.setState({ upgradeVersion: "", loading: true });
+                            }}
+                        />
+                    </Modal>
+                )}
+                <RevisionHeader
+                    showRevisions={this.props.showRevisions}
+                    isCurrent={isCurrent}
+                    onClick={() => {
+                        if (typeof this.props.toggleShowRevisions === "function") {
+                            this.props.toggleShowRevisions();
+                        }
+                        this.setState((prev) => ({
+                            ...prev,
+                            expandRevisions: !prev.expandRevisions,
+                        }));
+                    }}
+                >
+                    <RevisionPreview>
+                        <i className="material-icons">arrow_drop_down</i>
+                        {isCurrent
+                            ? `Current version`
+                            : `Previewing revision (not deployed)`}{" "}
+                        - <Revision>No. {this.props.chart.version}</Revision>
+                    </RevisionPreview>
+                </RevisionHeader>
+                <RevisionList>{this.renderExpanded()}</RevisionList>
+            </div>
+        );
+    };
+
+    render() {
+        return (
+            <StyledRevisionSection showRevisions={this.state.expandRevisions}>
+                {this.renderContents()}
+                {createPortal(
+                    <ConfirmOverlay
+                        show={this.state.rollbackRevision != null}
+                        message={`Are you sure you want to revert to version ${this.state.rollbackRevision}?`}
+                        onYes={this.handleRollback}
+                        onNo={() => this.setState({ rollbackRevision: null })}
+                    />,
+                    document.body
+                )}
+            </StyledRevisionSection>
+        );
+    }
+}
+
+PorterAppRevisionSection.contextType = Context;
+
+export default withAuth(PorterAppRevisionSection);
+
+const TableWrapper = styled.div`
+  padding-bottom: 20px;
+`;
+
+const LoadingPlaceholder = styled.div`
+  height: 40px;
+  display: flex;
+  align-items: center;
+  padding-left: 20px;
+`;
+
+const LoadingGif = styled.img`
+  width: 15px;
+  height: 15px;
+  margin-right: ${(props: { revision: boolean }) =>
+        props.revision ? "0px" : "9px"};
+  margin-left: ${(props: { revision: boolean }) =>
+        props.revision ? "10px" : "0px"};
+  margin-bottom: ${(props: { revision: boolean }) =>
+        props.revision ? "-2px" : "0px"};
+`;
+
+const StatusWrapper = styled.div`
+  display: flex;
+  align-items: center;
+  font-family: "Work Sans", sans-serif;
+  font-size: 13px;
+  color: #ffffff55;
+  margin-right: 25px;
+`;
+
+const RevisionList = styled.div`
+  overflow-y: auto;
+  max-height: 215px;
+`;
+
+const RollbackButton = styled.div`
+  cursor: ${(props: { disabled: boolean }) =>
+        props.disabled ? "not-allowed" : "pointer"};
+  display: flex;
+  border-radius: 3px;
+  align-items: center;
+  justify-content: center;
+  font-weight: 500;
+  height: 21px;
+  font-size: 13px;
+  width: 70px;
+  background: ${(props: { disabled: boolean }) =>
+        props.disabled ? "#aaaabbee" : "#616FEEcc"};
+  :hover {
+    background: ${(props: { disabled: boolean }) =>
+        props.disabled ? "" : "#405eddbb"};
+  }
+`;
+
+const Tr = styled.tr`
+  line-height: 2.2em;
+  cursor: ${(props: { disableHover?: boolean; selected?: boolean }) =>
+        props.disableHover ? "" : "pointer"};
+  background: ${(props: { disableHover?: boolean; selected?: boolean }) =>
+        props.selected ? "#ffffff11" : ""};
+  :hover {
+    background: ${(props: { disableHover?: boolean; selected?: boolean }) =>
+        props.disableHover ? "" : "#ffffff22"};
+  }
+`;
+
+const Td = styled.td`
+  font-size: 13px;
+  color: #ffffff;
+  padding-left: 32px;
+`;
+
+const Th = styled.td`
+  font-size: 13px;
+  font-weight: 500;
+  color: #aaaabb;
+  padding-left: 32px;
+`;
+
+const RevisionsTable = styled.table`
+  width: 100%;
+  margin-top: 5px;
+  padding-left: 32px;
+  padding-bottom: 20px;
+  min-width: 500px;
+  border-collapse: collapse;
+`;
+
+const Revision = styled.div`
+  color: #ffffff;
+  margin-left: 5px;
+`;
+
+const RevisionHeader = styled.div`
+  color: ${(props: { showRevisions: boolean; isCurrent: boolean }) =>
+        props.isCurrent ? "#ffffff66" : "#f5cb42"};
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  height: 40px;
+  font-size: 13px;
+  width: 100%;
+  padding-left: 10px;
+  cursor: pointer;
+  background: ${({ theme }) => theme.fg};
+  :hover {
+    background: ${(props) => props.showRevisions && props.theme.fg2};
+  }
+
+  > div > i {
+    margin-right: 8px;
+    font-size: 20px;
+    cursor: pointer;
+    border-radius: 20px;
+    transform: ${(props: { showRevisions: boolean; isCurrent: boolean }) =>
+        props.showRevisions ? "" : "rotate(-90deg)"};
+  }
+`;
+
+const StyledRevisionSection = styled.div`
+  width: 100%;
+  max-height: ${(props: { showRevisions: boolean }) =>
+        props.showRevisions ? "255px" : "40px"};
+  margin: 20px 0px 18px;
+  overflow: hidden;
+  border-radius: 5px;
+  background: ${props => props.theme.fg};
+  border: 1px solid #494b4f;
+  :hover {
+    border: 1px solid #7a7b80;
+  }
+  animation: ${(props: { showRevisions: boolean }) =>
+        props.showRevisions ? "expandRevisions 0.3s" : ""};
+  animation-timing-function: ease-out;
+  @keyframes expandRevisions {
+    from {
+      max-height: 40px;
+    }
+    to {
+      max-height: 250px;
+    }
+  }
+`;
+
+const RevisionPreview = styled.div`
+  display: flex;
+  align-items: center;
+`;
+
+const RevisionUpdateMessage = styled.div`
+  color: white;
+  display: flex;
+  align-items: center;
+  padding: 4px 10px;
+  border-radius: 5px;
+  margin-right: 10px;
+
+  :hover {
+    border: 1px solid white;
+    padding: 3px 9px;
+  }
+
+  > i {
+    margin-right: 6px;
+    font-size: 20px;
+    cursor: pointer;
+    border-radius: 20px;
+    transform: none;
+  }
+`;
+
+const A = styled.a`
+  color: #8590ff;
+  text-decoration: underline;
+  cursor: pointer;
+`;

+ 57 - 0
dashboard/src/main/home/app-dashboard/expanded-app/SettingsTab.tsx

@@ -0,0 +1,57 @@
+import Button from "components/porter/Button";
+import Spacer from "components/porter/Spacer";
+import Text from "components/porter/Text";
+
+import React, { useEffect, useState } from "react";
+import styled from "styled-components";
+import DeleteApplicationModal from "./DeleteApplicationModal";
+
+type Props = {
+    appName: string;
+    githubWorkflowFilename: string;
+    deleteApplication: (deleteWorkflowFile?: boolean) => void;
+};
+
+const SettingsTab: React.FC<Props> = ({
+    appName,
+    githubWorkflowFilename,
+    deleteApplication
+}) => {
+    const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
+
+    useEffect(() => {
+        // Do something
+    }, []);
+
+    return (
+        <StyledSettingsTab>
+            <Text size={16}>Delete "{appName}"</Text>
+            <Spacer y={1} />
+            <Text color="helper">
+                Delete this application and all of its resources.
+            </Text>
+            <Spacer y={1} />
+            <Button
+                onClick={() => {
+                    setIsDeleteModalOpen(true);
+                }}
+                color="#b91133"
+            >
+                Delete
+            </Button>
+            {isDeleteModalOpen &&
+                <DeleteApplicationModal
+                    closeModal={() => setIsDeleteModalOpen(false)}
+                    githubWorkflowFilename={githubWorkflowFilename}
+                    deleteApplication={deleteApplication}
+                />
+            }
+        </StyledSettingsTab>
+    );
+};
+
+export default SettingsTab;
+
+const StyledSettingsTab = styled.div`
+width: 100%;
+`;

+ 51 - 31
dashboard/src/main/home/app-dashboard/expanded-app/activity-feed/ActivityFeed.tsx

@@ -19,17 +19,17 @@ import _ from "lodash";
 import Button from "components/porter/Button";
 import Icon from "components/porter/Icon";
 import Container from "components/porter/Container";
-import EventFocusView from "./events/focus-views/EventFocusView";
-import { PorterAppEvent } from "shared/types";
+import { PorterAppEvent } from "./events/types";
 
 type Props = {
   chart: any;
   stackName: string;
   appData: any;
-  eventId?: string;
 };
 
-const ActivityFeed: React.FC<Props> = ({ chart, stackName, appData, eventId }) => {
+const EVENTS_POLL_INTERVAL = 5000; // poll every 5 seconds
+
+const ActivityFeed: React.FC<Props> = ({ chart, stackName, appData }) => {
   const { currentProject, currentCluster } = useContext(Context);
 
   const [events, setEvents] = useState<PorterAppEvent[]>([]);
@@ -39,6 +39,7 @@ const ActivityFeed: React.FC<Props> = ({ chart, stackName, appData, eventId }) =
   const [numPages, setNumPages] = useState<number>(0);
   const [hasPorterAgent, setHasPorterAgent] = useState(false);
   const [isPorterAgentInstalling, setIsPorterAgentInstalling] = useState(false);
+  const [shouldAnimate, setShouldAnimate] = useState(true);
 
   const getEvents = async () => {
     setLoading(true)
@@ -60,14 +61,38 @@ const ActivityFeed: React.FC<Props> = ({ chart, stackName, appData, eventId }) =
       );
 
       setNumPages(res.data.num_pages);
-      setEvents(res.data.events?.map((event: any) => PorterAppEvent.toPorterAppEvent(event)) ?? []);
+      setEvents(res.data.events);
     } catch (err) {
       setError(err);
     } finally {
       setLoading(false);
+      setShouldAnimate(false);
     }
   };
 
+  const updateEvents = async () => {
+    if (!currentProject || !currentCluster) {
+      return;
+    }
+    try {
+      const res = await api.getFeedEvents(
+        "<token>",
+        {},
+        {
+          cluster_id: currentCluster.id,
+          project_id: currentProject.id,
+          stack_name: stackName,
+          page,
+        }
+      );
+      setError(undefined)
+      setNumPages(res.data.num_pages);
+      setEvents(res.data.events?.map((event: any) => PorterAppEvent.toPorterAppEvent(event)) ?? []);
+    } catch (err) {
+      setError(err);
+    }
+  }
+
   useEffect(() => {
     const checkForAgent = async () => {
       const project_id = currentProject?.id;
@@ -92,10 +117,12 @@ const ActivityFeed: React.FC<Props> = ({ chart, stackName, appData, eventId }) =
     if (!hasPorterAgent) {
       checkForAgent();
     } else {
+      const intervalId = setInterval(updateEvents, EVENTS_POLL_INTERVAL);
       getEvents();
+      return () => clearInterval(intervalId);
     }
 
-  }, [currentProject, currentCluster, hasPorterAgent, page, eventId]);
+  }, [currentProject, currentCluster, hasPorterAgent, page]);
 
   const installAgent = async () => {
     const project_id = currentProject?.id;
@@ -140,13 +167,6 @@ const ActivityFeed: React.FC<Props> = ({ chart, stackName, appData, eventId }) =
     );
   }
 
-  if (eventId != null) {
-    return <EventFocusView
-      eventId={eventId}
-      appData={appData}
-    />;
-  }
-
   if (!loading && !hasPorterAgent) {
     return (
       <Fieldset>
@@ -178,13 +198,13 @@ const ActivityFeed: React.FC<Props> = ({ chart, stackName, appData, eventId }) =
   }
 
   return (
-    <StyledActivityFeed>
+    <StyledActivityFeed shouldAnimate={shouldAnimate}>
       {events.map((event, i) => {
         return (
           <EventWrapper isLast={i === events.length - 1} key={i}>
-            {i !== events.length - 1 && events.length > 1 && <Line />}
-            <Dot />
-            <Time>
+            {i !== events.length - 1 && events.length > 1 && <Line shouldAnimate={shouldAnimate} />}
+            <Dot shouldAnimate={shouldAnimate} />
+            <Time shouldAnimate={shouldAnimate}>
               <Text>{feedDate(event.created_at).split(", ")[0]}</Text>
               <Spacer x={0.5} />
               <Text>{feedDate(event.created_at).split(", ")[1]}</Text>
@@ -224,26 +244,26 @@ const I = styled.i`
   margin-right: 5px;
 `;
 
-const Time = styled.div`
-  opacity: 0;
-  animation: fadeIn 0.3s 0.1s;
-  animation-fill-mode: forwards;
+const Time = styled.div<{ shouldAnimate: boolean }>`
+  opacity: ${(props) => props.shouldAnimate ? "0" : "1"};
+  ${(props) => props.shouldAnimate && "animation: fadeIn 0.3s 0.1s;"}
+  ${(props) => props.shouldAnimate && "animation-fill-mode: forwards;"}
   width: 90px;
 `;
 
-const Line = styled.div`
+const Line = styled.div<{ shouldAnimate: boolean }>`
   width: 1px;
   height: calc(100% + 30px);
   background: #414141;
   position: absolute;
   left: 3px;
   top: 36px;
-  opacity: 0;
-  animation: fadeIn 0.3s 0.1s;
-  animation-fill-mode: forwards;
+  opacity: ${(props) => props.shouldAnimate ? "0" : "1"};
+  ${(props) => props.shouldAnimate && "animation: fadeIn 0.3s 0.1s;"}
+  ${(props) => props.shouldAnimate && "animation-fill-mode: forwards;"}
 `;
 
-const Dot = styled.div`
+const Dot = styled.div<{ shouldAnimate: boolean }>`
   width: 7px;
   height: 7px;
   background: #fff;
@@ -251,9 +271,9 @@ const Dot = styled.div`
   position: absolute;
   left: 0;
   top: 36px;
-  opacity: 0;
-  animation: fadeIn 0.3s 0.1s;
-  animation-fill-mode: forwards;
+  opacity: ${(props) => props.shouldAnimate ? "0" : "1"};
+  ${(props) => props.shouldAnimate && "animation: fadeIn 0.3s 0.1s;"}
+  ${(props) => props.shouldAnimate && "animation-fill-mode: forwards;"}
 `;
 
 const EventWrapper = styled.div<{
@@ -266,9 +286,9 @@ const EventWrapper = styled.div<{
   margin-bottom: ${(props) => (props.isLast ? "" : "25px")};
 `;
 
-const StyledActivityFeed = styled.div`
+const StyledActivityFeed = styled.div<{ shouldAnimate: boolean }>`
   width: 100%;
-  animation: fadeIn 0.3s 0s;
+  ${(props) => props.shouldAnimate && "animation: fadeIn 0.3s 0s;"}
   @keyframes fadeIn {
     from {
       opacity: 0;

+ 5 - 27
dashboard/src/main/home/app-dashboard/expanded-app/activity-feed/events/cards/AppEventCard.tsx

@@ -7,15 +7,14 @@ import Spacer from "components/porter/Spacer";
 import Link from "components/porter/Link";
 import Icon from "components/porter/Icon";
 
-import { PorterAppEvent } from "shared/types";
 import { StyledEventCard } from "./EventCard";
-import styled from "styled-components";
 import AppEventModal from "../../../status/AppEventModal";
 import { readableDate } from "shared/string_utils";
 import dayjs from "dayjs";
 import Anser from "anser";
 import api from "shared/api";
 import { Direction } from "../../../logs/types";
+import { PorterAppEvent } from "../types";
 
 type Props = {
   event: PorterAppEvent;
@@ -72,17 +71,15 @@ const AppEventCard: React.FC<Props> = ({ event, appData }) => {
       <Container row spaced>
         <Container row>
           <Icon height="16px" src={app_event} />
-          <Spacer inline width="10px" />
+          <Spacer inline x={1} />
           <Text>{event.metadata.summary}</Text>
         </Container>
       </Container>
       <Spacer y={0.5} />
       <Container row spaced>
-        <TempWrapper>
-          <Link onClick={getAppLogs} hasunderline>
-            View details
-          </Link>
-        </TempWrapper>
+        <Link onClick={getAppLogs} hasunderline>
+          View details
+        </Link>
       </Container>
       {showModal && (
         <AppEventModal
@@ -99,22 +96,3 @@ const AppEventCard: React.FC<Props> = ({ event, appData }) => {
 
 export default AppEventCard;
 
-const TempWrapper = styled.div`
-  margin-top: -3px;
-`;
-
-const ViewDetailsButton = styled.div<{ width?: string }>`
-  border-radius: 5px;
-  height: 30px;
-  font-size: 13px;
-  color: white;
-  display: flex;
-  align-items: center;
-  padding: 0px 10px;
-  background: #ffffff11;
-  border: 1px solid #aaaabb33;
-  cursor: pointer;
-  :hover {
-    border: 1px solid #7a7b80;
-  }
-`;

+ 8 - 5
dashboard/src/main/home/app-dashboard/expanded-app/activity-feed/events/cards/BuildEventCard.tsx

@@ -1,4 +1,4 @@
-import React, { useEffect, useState } from "react";
+import React, { useState } from "react";
 import styled from "styled-components";
 
 import build from "assets/build.png";
@@ -11,11 +11,14 @@ import Container from "components/porter/Container";
 import Spacer from "components/porter/Spacer";
 import Link from "components/porter/Link";
 import Icon from "components/porter/Icon";
+import api from "shared/api";
 import { Log } from "main/home/cluster-dashboard/expanded-chart/logs-section/useAgentLogs";
-import { PorterAppEvent } from "shared/types";
+import JSZip from "jszip";
+import Anser, { AnserJsonEntry } from "anser";
 import { getDuration, getStatusIcon, triggerWorkflow } from '../utils';
 import { StyledEventCard } from "./EventCard";
 import document from "assets/document.svg";
+import { PorterAppEvent } from "../types";
 
 type Props = {
   event: PorterAppEvent;
@@ -30,7 +33,7 @@ const BuildEventCard: React.FC<Props> = ({ event, appData }) => {
       case "FAILED":
         return <Text color="#FF6060">Build failed</Text>;
       default:
-        return <Text color="#aaaabb66">Build in progress...</Text>;
+        return <Text color="helper">Build in progress...</Text>;
     }
   };
 
@@ -41,7 +44,7 @@ const BuildEventCard: React.FC<Props> = ({ event, appData }) => {
       case "FAILED":
         return (
           <Wrapper>
-            <Link to={`/apps/${appData.app.name}/events/${event.id}`} hasunderline>
+            <Link to={`/apps/${appData.app.name}/events?event_id=${event.id}`} hasunderline>
               <Container row>
                 <Icon src={document} height="10px" />
                 <Spacer inline width="5px" />
@@ -91,7 +94,7 @@ const BuildEventCard: React.FC<Props> = ({ event, appData }) => {
       <Spacer y={0.5} />
       <Container row spaced>
         <Container row>
-          <Icon height="16px" src={getStatusIcon(event.status)} />
+          <Icon height="12px" src={getStatusIcon(event.status)} />
           <Spacer inline width="10px" />
           {renderStatusText(event)}
           <Spacer inline x={1} />

+ 146 - 35
dashboard/src/main/home/app-dashboard/expanded-app/activity-feed/events/cards/DeployEventCard.tsx

@@ -1,43 +1,144 @@
-import React, { useEffect, useState } from "react";
+import React, { useState } from "react";
 
 
 import deploy from "assets/deploy.png";
-import refresh from "assets/refresh.png";
+import document from "assets/document.svg";
 
 import Text from "components/porter/Text";
 import Container from "components/porter/Container";
 import Spacer from "components/porter/Spacer";
 import Icon from "components/porter/Icon";
-import Modal from "components/porter/Modal";
-import { PorterAppEvent } from "shared/types";
-import { getDuration, getStatusIcon } from '../utils';
+import { getStatusIcon } from '../utils';
 import { StyledEventCard } from "./EventCard";
 import styled from "styled-components";
-import Button from "components/porter/Button";
-import api from "shared/api";
 import Link from "components/porter/Link";
 import ChangeLogModal from "../../../ChangeLogModal";
+import { PorterAppDeployEvent } from "../types";
+import AnimateHeight from "react-animate-height";
 
 type Props = {
-  event: PorterAppEvent;
+  event: PorterAppDeployEvent;
   appData: any;
 };
 
 const DeployEventCard: React.FC<Props> = ({ event, appData }) => {
-  const [loading, setLoading] = useState<boolean>(false);
   const [diffModalVisible, setDiffModalVisible] = useState(false);
   const [revertModalVisible, setRevertModalVisible] = useState(false);
+  const [serviceStatusVisible, setServiceStatusVisible] = useState(false);
 
-  const renderStatusText = (event: PorterAppEvent) => {
+  const renderStatusText = () => {
     switch (event.status) {
       case "SUCCESS":
-        return event?.metadata?.image_tag ? <Text color="#68BF8B">Deployed <Code>{event?.metadata?.image_tag}</Code></Text> : <Text color="#68BF8B">Deployment successful</Text>;
+        return event.metadata.image_tag != null ?
+          event.metadata.service_status != null ?
+            <Text color="#68BF8B">
+              Deployed <Code>{event.metadata.image_tag}</Code> to {Object.keys(event.metadata.service_status).length} service{Object.keys(event.metadata.service_status).length === 1 ? "" : "s"}
+            </Text> :
+            <Text color="#68BF8B">
+              Deployed <Code>{event.metadata.image_tag}</Code>
+            </Text>
+          :
+          <Text color="#68BF8B">
+            Deployment successful
+          </Text>;
       case "FAILED":
-        return <Text color="#FF6060">Deployment failed</Text>;
+        if (event.metadata.service_status != null) {
+          let failedServices = 0;
+          for (const key in event.metadata.service_status) {
+            if (event.metadata.service_status[key] === "FAILED") {
+              failedServices++;
+            }
+          }
+          return (
+            <Text color="#FF6060">
+              Failed to deploy <Code>{event.metadata.image_tag}</Code> to {failedServices} service{failedServices === 1 ? "" : "s"}
+            </Text>
+          );
+        } else {
+          return (
+            <Text color="#FF6060">
+              Deployment failed
+            </Text>
+          );
+        }
+      case "CANCELED":
+        if (event.metadata.service_status != null) {
+          let canceledServices = 0;
+          for (const key in event.metadata.service_status) {
+            if (event.metadata.service_status[key] === "CANCELED") {
+              canceledServices++;
+            }
+          }
+          return (
+            <Text color="#FFBF00">
+              Canceled deploy of <Code>{event.metadata.image_tag}</Code> to {canceledServices} service{canceledServices === 1 ? "" : "s"}
+            </Text>
+          );
+        } else {
+          return (
+            <Text color="#FFBF00">
+              Deployment canceled
+            </Text>
+          );
+        }
       default:
-        return <Text color="#aaaabb66">Deployment in progress...</Text>;
+        if (event.metadata.service_status != null) {
+          return (
+            <Text color="helper">
+              Deploying <Code>{event.metadata.image_tag}</Code> to {Object.keys(event.metadata.service_status).length} service{Object.keys(event.metadata.service_status).length === 1 ? "" : "s"}...
+            </Text>
+          );
+        } else {
+          return (
+            <Text color="helper">
+              Deploying <Code>{event.metadata.image_tag}</Code> to {Object.keys(event.metadata.service_status).length} service{Object.keys(event.metadata.service_status).length === 1 ? "" : "s"}...
+            </Text>
+          );
+        }
     }
   };
+
+  const renderServiceStatus = () => {
+    const serviceStatus = event.metadata.service_status;
+    if (Object.keys(serviceStatus).length === 0) {
+      return (
+        <Container row>
+          <Text color="helper">No services found.</Text>
+        </Container>
+      );
+    }
+
+    return <ServiceStatusesContainer>
+      {Object.keys(serviceStatus).map((key) => {
+        return (
+          <Container key={key} row>
+            <Spacer inline x={1} />
+            <Container row>
+              <ServiceStatusContainer>
+                <Text>{key}</Text>
+              </ServiceStatusContainer>
+              <Spacer inline x={1} />
+              <ServiceStatusContainer>
+                <Icon height="12px" src={getStatusIcon(serviceStatus[key])} />
+                <Spacer inline x={0.5} />
+                <Text color="helper">{serviceStatus[key] === "PROGRESSING" ? "DEPLOYING" : serviceStatus[key]}</Text>
+              </ServiceStatusContainer>
+              <Spacer inline x={1} />
+              <ServiceStatusContainer>
+                <Link
+                  to={`/apps/${appData.app.name}/logs?version=${event.metadata.revision}&service=${key}`}
+                >
+                  <Icon height="12px" src={document} />
+                  <Spacer inline x={0.5} />
+                  Live logs
+                </Link>
+              </ServiceStatusContainer>
+            </Container>
+          </Container>
+        );
+      })}
+    </ServiceStatusesContainer>
+  }
   return (
     <StyledEventCard>
       <Container row spaced>
@@ -50,23 +151,33 @@ const DeployEventCard: React.FC<Props> = ({ event, appData }) => {
       <Spacer y={0.5} />
       <Container row spaced>
         <Container row>
-          <Icon height="16px" src={getStatusIcon(event.status)} />
+          <Icon height="12px" src={getStatusIcon(event.status)} />
           <Spacer inline width="10px" />
-          {renderStatusText(event)}
-          {appData?.chart?.version !== event.metadata?.revision && (
+          {renderStatusText()}
+          {event.metadata.service_status != null &&
+            <>
+              <Spacer inline x={1} />
+              <TempWrapper>
+                <Link hasunderline onClick={() => setServiceStatusVisible(!serviceStatusVisible)}>
+                  View service status
+                </Link>
+              </TempWrapper>
+            </>
+          }
+          {appData?.chart?.version !== event.metadata.revision && (
             <>
               <Spacer inline x={1} />
               <TempWrapper>
                 <Link hasunderline onClick={() => setRevertModalVisible(true)}>
-                  Revert to version {event?.metadata?.revision}
+                  Revert to version {event.metadata.revision}
                 </Link>
 
               </TempWrapper>
             </>
           )}
-          <Spacer inline width="15px" />
+          <Spacer inline x={1} />
           <TempWrapper>
-            {event?.metadata?.revision != 1 && (<Link hasunderline onClick={() => setDiffModalVisible(true)}>
+            {event.metadata.revision != 1 && (<Link hasunderline onClick={() => setDiffModalVisible(true)}>
               View changes
             </Link>)}
             {diffModalVisible && (
@@ -91,7 +202,10 @@ const DeployEventCard: React.FC<Props> = ({ event, appData }) => {
           </TempWrapper>
         </Container>
       </Container>
-
+      <AnimateHeight height={serviceStatusVisible ? "auto" : 0}>
+        <Spacer y={0.5} />
+        {event.metadata.service_status != null && renderServiceStatus()}
+      </AnimateHeight>
     </StyledEventCard>
   );
 };
@@ -107,20 +221,17 @@ const Code = styled.span`
   font-family: monospace;
 `;
 
-const RevertButton = styled.div<{ width?: string }>`
-  border-radius: 5px;
-  height: 30px;
-  font-size: 13px;
-  color: white;
+const ServiceStatusContainer = styled.div`
   display: flex;
-  justify-content: center;
-  align-items: center;
-  padding: 0px 10px;
-  background: #ffffff11;
-  border: 1px solid #aaaabb33;
-  cursor: pointer;
-  :hover {
-    border: 1px solid #7a7b80;
-  }
-  width: 92px;
+  align-items: center;  
+  width: 150px;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: nowrap;
 `;
+
+const ServiceStatusesContainer = styled.div`
+  display: flex;
+  flex-direction: column;
+  gap: 10px;
+ `; 

+ 2 - 2
dashboard/src/main/home/app-dashboard/expanded-app/activity-feed/events/cards/EventCard.tsx

@@ -1,11 +1,11 @@
 import React from "react";
 import styled from "styled-components";
 
-import { PorterAppEvent, PorterAppEventType } from "shared/types";
 import BuildEventCard from "./BuildEventCard";
 import PreDeployEventCard from "./PreDeployEventCard";
 import AppEventCard from "./AppEventCard";
 import DeployEventCard from "./DeployEventCard";
+import { PorterAppDeployEvent, PorterAppEvent, PorterAppEventType } from "../types";
 
 type Props = {
   event: PorterAppEvent;
@@ -20,7 +20,7 @@ const EventCard: React.FC<Props> = ({ event, appData }) => {
       case PorterAppEventType.BUILD:
         return <BuildEventCard event={event} appData={appData} />;
       case PorterAppEventType.DEPLOY:
-        return <DeployEventCard event={event} appData={appData} />;
+        return <DeployEventCard event={event as PorterAppDeployEvent} appData={appData} />;
       case PorterAppEventType.PRE_DEPLOY:
         return <PreDeployEventCard event={event} appData={appData} />;
       default:

+ 3 - 3
dashboard/src/main/home/app-dashboard/expanded-app/activity-feed/events/cards/PreDeployEventCard.tsx

@@ -11,11 +11,11 @@ import Container from "components/porter/Container";
 import Spacer from "components/porter/Spacer";
 import Icon from "components/porter/Icon";
 
-import { PorterAppEvent } from "shared/types";
 import { getDuration, getStatusIcon, triggerWorkflow } from '../utils';
 import { StyledEventCard } from "./EventCard";
 import Link from "components/porter/Link";
 import document from "assets/document.svg";
+import { PorterAppEvent } from "../types";
 
 type Props = {
   event: PorterAppEvent;
@@ -51,14 +51,14 @@ const PreDeployEventCard: React.FC<Props> = ({ event, appData }) => {
       <Spacer y={0.5} />
       <Container row spaced>
         <Container row>
-          <Icon height="16px" src={getStatusIcon(event.status)} />
+          <Icon height="12px" src={getStatusIcon(event.status)} />
           <Spacer inline width="10px" />
           {renderStatusText(event)}
           {(event.status !== "SUCCESS") &&
             <>
               <Spacer inline x={1} />
               <Wrapper>
-                <Link to={`/apps/${appData.app.name}/events/${event.id}`} hasunderline>
+                <Link to={`/apps/${appData.app.name}/events?event_id=${event.id}`} hasunderline>
                   <Container row>
                     <Icon src={document} height="10px" />
                     <Spacer inline width="5px" />

+ 3 - 3
dashboard/src/main/home/app-dashboard/expanded-app/activity-feed/events/focus-views/BuildFailureEventFocusView.tsx

@@ -6,12 +6,12 @@ import styled from "styled-components";
 import Anser, { AnserJsonEntry } from "anser";
 import JSZip from "jszip";
 import dayjs from "dayjs";
-import { PorterAppEvent } from "shared/types";
 import Text from "components/porter/Text";
 import { readableDate } from "shared/string_utils";
 import { getDuration } from "../utils";
 import Link from "components/porter/Link";
 import { PorterLog } from "../../../logs/types";
+import { PorterAppEvent } from "../types";
 
 type Props = {
     event: PorterAppEvent;
@@ -24,7 +24,7 @@ const BuildFailureEventFocusView: React.FC<Props> = ({
 }) => {
     const [logs, setLogs] = useState<PorterLog[]>([]);
     const [isLoading, setIsLoading] = useState<boolean>(true);
-    const scrollToBottomRef = useRef<HTMLDivElement | undefined>(undefined);
+    const scrollToBottomRef = useRef<HTMLDivElement>(null);
 
     useEffect(() => {
         if (!isLoading && scrollToBottomRef.current) {
@@ -118,7 +118,7 @@ const BuildFailureEventFocusView: React.FC<Props> = ({
 
     useEffect(() => {
         getBuildLogs();
-    }, [event]);
+    }, []);
 
     return (
         <>

+ 71 - 0
dashboard/src/main/home/app-dashboard/expanded-app/activity-feed/events/focus-views/DeployEventFocusView.tsx

@@ -0,0 +1,71 @@
+import Spacer from "components/porter/Spacer";
+import React from "react";
+import dayjs from "dayjs";
+import Text from "components/porter/Text";
+import { readableDate } from "shared/string_utils";
+import { getDuration } from "../utils";
+import LogSection from "../../../logs/LogSection";
+import { AppearingView } from "./EventFocusView";
+import Icon from "components/porter/Icon";
+import loading from "assets/loading.gif";
+import Container from "components/porter/Container";
+import { PorterAppDeployEvent } from "../types";
+import { LogFilterQueryParamOpts } from "../../../logs/types";
+
+type Props = {
+    event: PorterAppDeployEvent;
+    appData: any;
+    filterOpts?: LogFilterQueryParamOpts
+};
+
+const DeployEventFocusView: React.FC<Props> = ({
+    event,
+    appData,
+    filterOpts,
+}) => {
+    const renderHeaderText = () => {
+        switch (event.status) {
+            case "SUCCESS":
+                return <Text color="#68BF8B" size={16}>Deploy succeeded</Text>;
+            case "FAILED":
+                return <Text color="#FF6060" size={16}>Deploy failed</Text>;
+            case "CANCELED":
+                return <Text color="#FFBF00" size={16}>Deploy canceled</Text>;
+            default:
+                return (
+                    <Container row>
+                        <Icon height="16px" src={loading} />
+                        <Spacer inline width="10px" />
+                        <Text size={16}>Deploy in progress...</Text>
+                    </Container>
+                );
+        }
+    };
+
+    const renderDurationText = () => {
+        switch (event.status) {
+            case "PROGRESSING":
+                return <Text color="helper">Started {readableDate(event.created_at)}.</Text>
+            default:
+                return <Text color="helper">Started {readableDate(event.created_at)} and ran for {getDuration(event)}.</Text>;
+        }
+    }
+
+    return (
+        <>
+            <AppearingView>
+                {renderHeaderText()}
+            </AppearingView>
+            <Spacer y={0.5} />
+            {renderDurationText()}
+            <Spacer y={0.5} />
+            <LogSection
+                currentChart={appData.chart}
+                appName={appData.app.name}
+                filterOpts={filterOpts}
+            />
+        </>
+    );
+};
+
+export default DeployEventFocusView;

+ 12 - 2
dashboard/src/main/home/app-dashboard/expanded-app/activity-feed/events/focus-views/EventFocusView.tsx

@@ -4,22 +4,26 @@ import React, { useContext, useEffect, useState } from "react";
 import { Context } from "shared/Context";
 import api from "shared/api";
 import styled from "styled-components";
-import { PorterAppEvent } from "shared/types";
 import Link from "components/porter/Link";
 import BuildFailureEventFocusView from "./BuildFailureEventFocusView";
 import PreDeployEventFocusView from "./PredeployEventFocusView";
 import _ from "lodash";
+import { PorterAppDeployEvent, PorterAppEvent } from "../types";
+import DeployEventFocusView from "./DeployEventFocusView";
+import { LogFilterQueryParamOpts } from "../../../logs/types";
 
 type Props = {
     eventId: string;
     appData: any;
+    filterOpts?: LogFilterQueryParamOpts;
 };
 
-const EVENT_POLL_INTERVAL = 15000; // poll every 15 seconds
+const EVENT_POLL_INTERVAL = 5000; // poll every 5 seconds
 
 const EventFocusView: React.FC<Props> = ({
     eventId,
     appData,
+    filterOpts,
 }) => {
     const { currentProject, currentCluster } = useContext(Context);
     const [event, setEvent] = useState<PorterAppEvent | null>(null);
@@ -59,6 +63,12 @@ const EventFocusView: React.FC<Props> = ({
                 return <BuildFailureEventFocusView event={event} appData={appData} />
             case "PRE_DEPLOY":
                 return <PreDeployEventFocusView event={event} appData={appData} />
+            case "DEPLOY":
+                return <DeployEventFocusView
+                    event={event as PorterAppDeployEvent}
+                    appData={appData}
+                    filterOpts={filterOpts}
+                />
             default:
                 return null
         }

+ 4 - 2
dashboard/src/main/home/app-dashboard/expanded-app/activity-feed/events/focus-views/PredeployEventFocusView.tsx

@@ -1,7 +1,6 @@
 import Spacer from "components/porter/Spacer";
 import React from "react";
 import dayjs from "dayjs";
-import { PorterAppEvent } from "shared/types";
 import Text from "components/porter/Text";
 import { readableDate } from "shared/string_utils";
 import { getDuration } from "../utils";
@@ -10,6 +9,7 @@ import { AppearingView } from "./EventFocusView";
 import Icon from "components/porter/Icon";
 import loading from "assets/loading.gif";
 import Container from "components/porter/Container";
+import { PorterAppEvent } from "../types";
 
 type Props = {
   event: PorterAppEvent;
@@ -30,7 +30,8 @@ const PreDeployEventFocusView: React.FC<Props> = ({
         return (
           <Container row>
             <Icon height="16px" src={loading} />
-            <Spacer inline width="10px" /><Text size={16}>Pre-deploy in progress...</Text>
+            <Spacer inline width="10px" />
+            <Text size={16}>Pre-deploy in progress...</Text>
           </Container>
         );
     }
@@ -60,6 +61,7 @@ const PreDeployEventFocusView: React.FC<Props> = ({
           endTime: event.metadata.end_time != null ? dayjs(event.metadata.end_time).add(1, 'minute') : undefined,
         }}
         showFilter={false}
+        appName={appData.app.name}
       />
     </>
   );

+ 30 - 0
dashboard/src/main/home/app-dashboard/expanded-app/activity-feed/events/types.ts

@@ -0,0 +1,30 @@
+export enum PorterAppEventType {
+    BUILD = "BUILD",
+    DEPLOY = "DEPLOY",
+    APP_EVENT = "APP_EVENT",
+    PRE_DEPLOY = "PRE_DEPLOY",
+}
+export interface PorterAppEvent {
+    created_at: string;
+    updated_at: string;
+    id: string;
+    status: string;
+    type: PorterAppEventType;
+    type_source: string;
+    porter_app_id: number;
+    metadata: any;
+}
+export const PorterAppEvent = {
+    toPorterAppEvent: (data: any): PorterAppEvent => {
+        return {
+            created_at: data.created_at ?? "",
+            updated_at: data.updated_at ?? "",
+            id: data.id ?? "",
+            status: data.status ?? "",
+            type: data.type ?? "",
+            type_source: data.type_source ?? "",
+            porter_app_id: data.porter_app_id ?? "",
+            metadata: data.metadata ?? {},
+        };
+    }
+}

+ 5 - 2
dashboard/src/main/home/app-dashboard/expanded-app/activity-feed/events/utils.ts

@@ -1,8 +1,9 @@
-import { PorterAppEvent } from "shared/types";
 import healthy from "assets/status-healthy.png";
-import failure from "assets/failure.png";
+import failure from "assets/failure.svg";
 import loading from "assets/loading.gif";
+import canceled from "assets/canceled.svg"
 import api from "shared/api";
+import { PorterAppEvent } from "./types";
 
 export const getDuration = (event: PorterAppEvent): string => {
     const startTimeStamp = new Date(event.metadata.start_time ?? event.created_at).getTime();
@@ -44,6 +45,8 @@ export const getStatusIcon = (status: string) => {
             return failure;
         case "PROGRESSING":
             return loading;
+        case "CANCELED":
+            return canceled;
         default:
             return loading;
     }

+ 45 - 0
dashboard/src/main/home/app-dashboard/expanded-app/logs/LogFilterComponent.tsx

@@ -0,0 +1,45 @@
+import Text from "components/porter/Text";
+import React from "react";
+import styled from "styled-components";
+import { GenericLogFilter } from "./types";
+import Spacer from "components/porter/Spacer";
+import Select from "components/porter/Select";
+
+type Props = {
+    filter: GenericLogFilter;
+    selectedValue: string;
+};
+
+const LogFilterComponent: React.FC<Props> = ({
+    filter,
+    selectedValue,
+}) => {
+    return (
+        <StyledLogFilterComponent>
+            <Text>{filter.displayName}</Text>
+            <Spacer inline x={0.5} />
+            <Select
+                options={[filter.default, ...filter.options]}
+                height={"30px"}
+                value={selectedValue}
+                setValue={filter.setValue}
+            />
+        </StyledLogFilterComponent>
+    );
+};
+
+export default LogFilterComponent;
+
+const StyledLogFilterComponent = styled.div`
+    display: flex;
+    align-items: center;
+    animation: fadeIn 0.3s 0s;
+    @keyframes fadeIn {
+    from {
+        opacity: 0;
+    }
+    to {
+        opacity: 1;
+    }
+    }
+`;

+ 72 - 0
dashboard/src/main/home/app-dashboard/expanded-app/logs/LogFilterContainer.tsx

@@ -0,0 +1,72 @@
+import React from "react";
+
+import styled from "styled-components";
+import filterOutline from "assets/filter-outline-icon.svg";
+import filterOutlineWhite from "assets/filter-outline-white.svg";
+import { GenericLogFilter, LogFilterName } from "./types";
+import Icon from "components/porter/Icon";
+import Spacer from "components/porter/Spacer";
+import LogFilterComponent from "./LogFilterComponent";
+
+type Props = {
+    filters: GenericLogFilter[];
+    selectedFilterValues: Record<LogFilterName, string>;
+};
+
+const LogFilterContainer: React.FC<Props> = (props) => {
+    const getIcon = () => {
+        if (props.filters.every((filter) => GenericLogFilter.isDefault(filter, props.selectedFilterValues[filter.name]))) {
+            return filterOutline;
+        }
+        return filterOutlineWhite;
+    }
+
+    const renderFilters = () => {
+        return (
+            <FiltersContainer>
+                {props.filters.map((filter, i) => {
+                    return <LogFilterComponent
+                        key={i}
+                        filter={filter}
+                        selectedValue={props.selectedFilterValues[filter.name]}
+                    />
+                })}
+            </FiltersContainer>
+        )
+    }
+
+    return (
+        <StyledLogFilterContainer>
+            <Icon src={getIcon()} height={"16px"} />
+            <Spacer inline x={1} />
+            <Bar />
+            <Spacer inline x={1} />
+            {renderFilters()}
+        </StyledLogFilterContainer>
+    );
+};
+
+export default LogFilterContainer;
+
+const Bar = styled.div`
+  width: 1px;
+  height: calc(18px);
+  background: #494b4f;
+`;
+
+const StyledLogFilterContainer = styled.div`
+  font-size: 13px;
+  padding: 10px;
+  background: ${(props) => props.theme.fg};
+  border-radius: 5px;
+  display: flex;
+  align-items: center;
+  border: 1px solid #494b4f;
+  width: fit-content;
+`;
+
+const FiltersContainer = styled.div`
+    display: flex;
+    flex-wrap: wrap;
+    gap: 10px;
+`

+ 147 - 201
dashboard/src/main/home/app-dashboard/expanded-app/logs/LogSection.tsx

@@ -7,16 +7,12 @@ import React, {
 } from "react";
 
 import styled from "styled-components";
-import RadioFilter from "components/RadioFilter";
 
 import spinner from "assets/loading.gif";
-import filterOutline from "assets/filter-outline.svg";
-import filterOutlineWhite from "assets/filter-outline-white.svg";
 import { Context } from "shared/Context";
 import api from "shared/api";
 import { useLogs } from "./utils";
-import { Direction } from "./types";
-import Anser from "anser";
+import { Direction, GenericFilterOption, GenericLogFilter, LogFilterName, LogFilterQueryParamOpts } from "./types";
 import dayjs, { Dayjs } from "dayjs";
 import Loading from "components/Loading";
 import _ from "lodash";
@@ -30,35 +26,31 @@ import Spacer from "components/porter/Spacer";
 import Container from "components/porter/Container";
 import Button from "components/porter/Button";
 import { Service } from "../../new-app-flow/serviceTypes";
+import LogFilterContainer from "./LogFilterContainer";
+import StyledLogs from "./StyledLogs";
 
 type Props = {
-  currentChart?: ChartType;
+  appName: string;
+  currentChart: ChartType;
   services?: Service[];
   timeRange?: {
     startTime?: Dayjs;
     endTime?: Dayjs;
   };
   showFilter?: boolean;
-};
-
-type PodFilter = {
-  podName: string;
-  podType: string;
+  filterOpts?: LogFilterQueryParamOpts;
 };
 
 const LogSection: React.FC<Props> = ({
   currentChart,
   services,
   timeRange,
+  appName,
+  filterOpts,
   showFilter = true,
 }) => {
   const scrollToBottomRef = useRef<HTMLDivElement | undefined>(undefined);
   const { currentProject, currentCluster } = useContext(Context);
-  const [podFilter, setPodFilter] = useState<PodFilter>({
-    podName: "",
-    podType: "",
-  });
-  const [podFilterOpts, setPodFilterOpts] = useState<PodFilter[]>([]);
   const [scrollToBottomEnabled, setScrollToBottomEnabled] = useState(true);
   const [enteredSearchText, setEnteredSearchText] = useState("");
   const [searchText, setSearchText] = useState("");
@@ -69,6 +61,101 @@ const LogSection: React.FC<Props> = ({
   const [isPorterAgentInstalling, setIsPorterAgentInstalling] = useState(false);
   const [isLoading, setIsLoading] = useState(true);
   const [logsError, setLogsError] = useState<string | undefined>(undefined);
+  const getSelectorFromServiceQueryParam = (serviceName: string | null | undefined) => {
+    if (serviceName == null) {
+      return undefined;
+    }
+    const match = services?.find(s => s.name == serviceName);
+    if (match == null) {
+      return undefined;
+    }
+    return `${match.name}-${match.type == "worker" ? "wkr" : match.type}`;
+  }
+  const [selectedFilterValues, setSelectedFilterValues] = useState<Record<LogFilterName, string>>({
+    revision: filterOpts?.revision ?? GenericLogFilter.getDefaultOption("revision").value,
+    output_stream: filterOpts?.output_stream ?? GenericLogFilter.getDefaultOption("output_stream").value,
+    pod_name: getSelectorFromServiceQueryParam(filterOpts?.service) ?? GenericLogFilter.getDefaultOption("pod_name").value,
+  });
+
+  const createVersionOptions = (number: number) => {
+    return Array.from({ length: number }, (_, index) => {
+      const version = index + 1;
+      const label = version === number ? `Version ${version} (latest)` : `Version ${version}`;
+      const value = version.toString();
+      return GenericFilterOption.of(label, value);
+    }).reverse().slice(0, 3);
+  }
+
+  const isAgentVersionUpdated = (agentImage: string | undefined) => {
+    if (agentImage == null) {
+      return false;
+    }
+    const version = agentImage.split(":").pop();
+    //make sure version is above v3.1.3
+    if (version == null) {
+      return false;
+    }
+    const versionParts = version.split(".");
+    if (versionParts.length < 3) {
+      return false;
+    }
+    const major = parseInt(versionParts[0]);
+    const minor = parseInt(versionParts[1]);
+    const patch = parseInt(versionParts[2]);
+    if (major < 3) {
+      return false;
+    }
+    if (minor < 1) {
+      return false;
+    }
+    if (patch < 3) {
+      return false;
+    }
+    return true;
+  }
+
+  const [filters, setFilters] = useState<GenericLogFilter[]>(showFilter ? [
+    {
+      name: "pod_name",
+      displayName: "Service",
+      default: GenericLogFilter.getDefaultOption("pod_name"),
+      options: services?.map(s => {
+        return GenericFilterOption.of(s.name, `${s.name}-${s.type == "worker" ? "wkr" : s.type}`)
+      }) ?? [],
+      setValue: (value: string) => {
+        setSelectedFilterValues((s) => ({
+          ...s,
+          pod_name: value,
+        }));
+      }
+    },
+    {
+      name: "revision",
+      displayName: "Version",
+      default: GenericLogFilter.getDefaultOption("revision"),
+      options: currentChart != null ? createVersionOptions(currentChart.version) : [],
+      setValue: (value: string) => {
+        setSelectedFilterValues((s) => ({
+          ...s,
+          revision: value,
+        }));
+      }
+    },
+    {
+      name: "output_stream",
+      displayName: "Output Stream",
+      default: GenericLogFilter.getDefaultOption("output_stream"),
+      options: [
+        GenericFilterOption.of("stderr", "stderr"),
+      ],
+      setValue: (value: string) => {
+        setSelectedFilterValues((s) => ({
+          ...s,
+          output_stream: value,
+        }));
+      }
+    },
+  ] : []);
 
   const notify = (message: string) => {
     setNotification(message);
@@ -78,12 +165,10 @@ const LogSection: React.FC<Props> = ({
     }, 5000);
   };
 
-  const namespace = currentChart == null ? "" : currentChart.namespace;
-
   const { logs, refresh, moveCursor, paginationInfo } = useLogs(
-    podFilter.podName,
-    podFilter.podType,
-    namespace,
+    selectedFilterValues,
+    appName,
+    currentChart == null ? "" : currentChart.namespace,
     enteredSearchText,
     notify,
     currentChart,
@@ -92,20 +177,6 @@ const LogSection: React.FC<Props> = ({
     timeRange,
   );
 
-  const refreshPodLogsValues = async () => {
-    if (currentChart == null || services == null) {
-      setPodFilterOpts([]);
-    } else {
-      const podList = services.map((service: Service) => {
-        return {
-          podName: service.name,
-          podType: service.type == "worker" ? "wkr" : service.type,
-        };
-      });
-      setPodFilterOpts(podList);
-    }
-  };
-
   useEffect(() => {
     if (!isLoading && scrollToBottomRef.current && scrollToBottomEnabled) {
       const scrollPosition = scrollToBottomRef.current.offsetTop + scrollToBottomRef.current.offsetHeight - window.innerHeight;
@@ -116,71 +187,15 @@ const LogSection: React.FC<Props> = ({
     }
   }, [isLoading, logs, scrollToBottomRef, scrollToBottomEnabled]);
 
-  useEffect(() => {
-    if (podFilter.podName != "") {
-      setSelectedDateIfUndefined();
-      return;
-    }
-  }, [podFilter]);
-
-  useEffect(() => {
-    if (selectedDate == null) {
-      resetPodFilter();
-      return;
-    }
-  }, [selectedDate]);
-
-  const resetPodFilter = () => {
-    if (podFilter.podName != "" || podFilter.podType != "") {
-      setPodFilter({ podName: "", podType: "" });
-    }
-  };
 
-  const renderLogs = () => {
-    return logs?.map((log, i) => {
-      return (
-        <Log key={[log.lineNumber, i].join(".")}>
-          <span className="line-number">{log.lineNumber}.</span>
-          <span className="line-timestamp">
-            {log.timestamp
-              ? dayjs(log.timestamp).format("MMM D, YYYY HH:mm:ss")
-              : "-"}
-          </span>
-          <LogOuter key={[log.lineNumber, i].join(".")}>
-            {log.line?.map((ansi, j) => {
-              if (ansi.clearLine) {
-                return null;
-              }
-
-              return (
-                <LogInnerSpan
-                  key={[log.lineNumber, i, j].join(".")}
-                  ansi={ansi}
-                >
-                  {ansi.content.replace(/ /g, "\u00a0")}
-                </LogInnerSpan>
-              );
-            })}
-          </LogOuter>
-        </Log>
-      );
+  const resetFilters = () => {
+    setSelectedFilterValues({
+      revision: filterOpts?.revision ?? GenericLogFilter.getDefaultOption("revision").value,
+      output_stream: filterOpts?.output_stream ?? GenericLogFilter.getDefaultOption("output_stream").value,
+      pod_name: getSelectorFromServiceQueryParam(filterOpts?.service) ?? GenericLogFilter.getDefaultOption("pod_name").value,
     });
   };
 
-  const setPodFilterWithPodName = (podName: string) => {
-    if (podName == "All") {
-      resetPodFilter();
-      return;
-    }
-
-    const filtered = podFilterOpts.filter((pod) => pod.podName == podName);
-    if (filtered.length > 0) {
-      setPodFilter(filtered[0]);
-    } else {
-      resetPodFilter();
-    }
-  };
-
   const onLoadPrevious = useCallback(() => {
     if (!selectedDate) {
       setSelectedDate(dayjs(logs[0].timestamp).toDate());
@@ -193,6 +208,7 @@ const LogSection: React.FC<Props> = ({
   const resetSearch = () => {
     setSearchText("");
     setEnteredSearchText("");
+    resetFilters();
   };
 
   const setSelectedDateIfUndefined = () => {
@@ -202,14 +218,6 @@ const LogSection: React.FC<Props> = ({
   };
 
   const renderContents = () => {
-    const radioOptions = podFilterOpts?.map((pod) => {
-      return {
-        value: pod.podName,
-        label: pod.podName,
-      };
-    });
-    radioOptions.unshift({ value: "All", label: "All" });
-
     return (
       <>
         <FlexRow>
@@ -225,17 +233,6 @@ const LogSection: React.FC<Props> = ({
               setSelectedDate={setSelectedDate}
               resetSearch={resetSearch}
             />
-            {showFilter &&
-              <RadioFilter
-                icon={
-                  podFilter.podName == "" ? filterOutline : filterOutlineWhite
-                }
-                selected={podFilter.podName}
-                setSelected={setPodFilterWithPodName}
-                options={radioOptions}
-                name="Filter logs"
-              />
-            }
           </Flex>
           <Flex>
             <ScrollButton onClick={() => setScrollToBottomEnabled((s) => !s)}>
@@ -247,7 +244,6 @@ const LogSection: React.FC<Props> = ({
             <Spacer inline width="10px" />
             <ScrollButton
               onClick={() => {
-                refreshPodLogsValues();
                 refresh();
               }}
             >
@@ -256,6 +252,16 @@ const LogSection: React.FC<Props> = ({
             </ScrollButton>
           </Flex>
         </FlexRow>
+        <Spacer y={0.5} />
+        {showFilter &&
+          <>
+            <LogFilterContainer
+              filters={filters}
+              selectedFilterValues={selectedFilterValues}
+            />
+            <Spacer y={0.5} />
+          </>
+        }
         <LogsSectionWrapper>
           <StyledLogsSection>
             {isLoading || (logs.length == 0 && selectedDate == null) ? (
@@ -281,7 +287,11 @@ const LogSection: React.FC<Props> = ({
                 >
                   Load Previous
                 </LoadMoreButton>
-                {renderLogs()}
+                <StyledLogs
+                  logs={logs}
+                  appName={appName}
+                  filters={filters}
+                />
                 <LoadMoreButton
                   active={selectedDate && logs.length !== 0}
                   role="button"
@@ -343,7 +353,6 @@ const LogSection: React.FC<Props> = ({
             })
             .then((res) => {
               setHasPorterAgent(true);
-              refreshPodLogsValues();
               setIsPorterAgentInstalling(false);
               setIsLoading(false);
             })
@@ -352,6 +361,26 @@ const LogSection: React.FC<Props> = ({
               setLogsError(err);
               setIsLoading(false);
             });
+
+          const agentImage = res.data?.image;
+          if (!isAgentVersionUpdated(agentImage)) {
+            setFilters([
+              {
+                name: "pod_name",
+                displayName: "Service",
+                default: GenericLogFilter.getDefaultOption("pod_name"),
+                options: services?.map(s => {
+                  return GenericFilterOption.of(s.name, `${s.name}-${s.type == "worker" ? "wkr" : s.type}`)
+                }) ?? [],
+                setValue: (value: string) => {
+                  setSelectedFilterValues((s) => ({
+                    ...s,
+                    pod_name: value,
+                  }));
+                }
+              },
+            ])
+          }
         }
       })
       .catch((err) => {
@@ -480,7 +509,6 @@ const ScrollButton = styled.div`
 const Flex = styled.div`
   display: flex;
   align-items: center;
-  border-bottom: 25px solid transparent;
 `;
 
 const Message = styled.div`
@@ -544,55 +572,6 @@ const StyledLogsSection = styled.div`
   }
 `;
 
-const Log = styled.div`
-  font-family: monospace;
-  user-select: text;
-  display: flex;
-  align-items: flex-end;
-  gap: 8px;
-  width: 100%;
-  & > * {
-    padding-block: 5px;
-  }
-  & > .line-timestamp {
-    height: 100%;
-    color: #949effff;
-    opacity: 0.5;
-    font-family: monospace;
-    min-width: fit-content;
-    padding-inline-end: 5px;
-  }
-  & > .line-number {
-    height: 100%;
-    background: #202538;
-    display: inline-block;
-    text-align: right;
-    min-width: 45px;
-    padding-inline-end: 5px;
-    opacity: 0.3;
-    font-family: monospace;
-  }
-`;
-
-const LogOuter = styled.div`
-  display: inline-block;
-  word-wrap: anywhere;
-  flex-grow: 1;
-  font-family: monospace, sans-serif;
-  font-size: 12px;
-`;
-
-const LogInnerSpan = styled.span`
-  font-family: monospace, sans-serif;
-  font-size: 12px;
-  font-weight: ${(props: { ansi: Anser.AnserJsonEntry }) =>
-    props.ansi?.decoration && props.ansi?.decoration == "bold" ? "700" : "400"};
-  color: ${(props: { ansi: Anser.AnserJsonEntry }) =>
-    props.ansi?.fg ? `rgb(${props.ansi?.fg})` : "white"};
-  background-color: ${(props: { ansi: Anser.AnserJsonEntry }) =>
-    props.ansi?.bg ? `rgb(${props.ansi?.bg})` : "transparent"};
-`;
-
 const LoadMoreButton = styled.div<{ active: boolean }>`
   width: 100%;
   display: ${(props) => (props.active ? "flex" : "none")};
@@ -604,39 +583,6 @@ const LoadMoreButton = styled.div<{ active: boolean }>`
   font-family: monospace;
 `;
 
-const ToggleOption = styled.div<{ selected: boolean; nudgeLeft?: boolean }>`
-  padding: 0 10px;
-  color: ${(props) => (props.selected ? "" : "#494b4f")};
-  border: 1px solid #494b4f;
-  height: 100%;
-  display: flex;
-  margin-left: ${(props) => (props.nudgeLeft ? "-1px" : "")};
-  align-items: center;
-  border-radius: ${(props) =>
-    props.nudgeLeft ? "0 5px 5px 0" : "5px 0 0 5px"};
-  :hover {
-    border: 1px solid #7a7b80;
-    z-index: 2;
-  }
-`;
-
-const ToggleButton = styled.div`
-  background: #26292e;
-  border-radius: 5px;
-  font-size: 13px;
-  height: 30px;
-  display: flex;
-  align-items: center;
-  cursor: pointer;
-`;
-
-const TimeIcon = styled.img<{ selected?: boolean }>`
-  width: 16px;
-  height: 16px;
-  z-index: 2;
-  opacity: ${(props) => (props.selected ? "" : "50%")};
-`;
-
 const NotificationWrapper = styled.div<{ active?: boolean }>`
   position: absolute;
   bottom: 10px;

+ 161 - 0
dashboard/src/main/home/app-dashboard/expanded-app/logs/StyledLogs.tsx

@@ -0,0 +1,161 @@
+import React from "react";
+import { GenericLogFilter, PorterLog } from "./types";
+import styled from "styled-components";
+import Anser from "anser";
+import dayjs from "dayjs";
+import { getPodSelectorFromPodNameAndAppName, getServiceNameFromPodNameAndAppName, getVersionTagColor } from "./utils";
+
+
+type Props = {
+    logs: PorterLog[];
+    appName: string;
+    filters: GenericLogFilter[];
+};
+
+const StyledLogs: React.FC<Props> = ({
+    logs,
+    appName,
+    filters,
+}) => {
+    const renderFilterTagForLog = (filter: GenericLogFilter, log: PorterLog, index: number) => {
+        if (log.metadata == null) {
+            return null;
+        }
+        switch (filter.name) {
+            case "revision":
+                if (log.metadata.revision == null || log.metadata.revision === "") {
+                    return null;
+                }
+                return (
+                    <LogInnerPill
+                        color={getVersionTagColor(log.metadata.revision)}
+                        key={index}
+                        onClick={() => filter.setValue(log.metadata.revision)}
+                    >
+                        {`Version: ${log.metadata.revision}`}
+                    </LogInnerPill>
+                )
+            case "pod_name":
+                if (log.metadata.pod_name == null || log.metadata.pod_name === "") {
+                    return null;
+                }
+                return (
+                    <LogInnerPill
+                        color={"white"}
+                        key={index}
+                        onClick={() => filter.setValue(getPodSelectorFromPodNameAndAppName(log.metadata.pod_name, appName))}
+                    >
+                        {getServiceNameFromPodNameAndAppName(log.metadata.pod_name, appName)}
+                    </LogInnerPill>
+                )
+            default:
+                return null;
+        }
+    }
+
+    return (
+        <StyledLogsContainer>
+            {logs.map((log, i) => {
+                return (
+                    <Log key={[log.lineNumber, i].join(".")}>
+                        <LogLabelsContainer>
+                            <LineTimestamp className="line-timestamp">
+                                {log.timestamp
+                                    ? dayjs(log.timestamp).format("MM/DD HH:mm:ss")
+                                    : "-"}
+                            </LineTimestamp>
+                            {filters.map((filter, j) => {
+                                return renderFilterTagForLog(filter, log, j)
+                            })}
+                        </LogLabelsContainer>
+                        <LogOuter key={[log.lineNumber, i].join(".")}>
+                            {log.line?.map((ansi, j) => {
+                                if (ansi.clearLine) {
+                                    return null;
+                                }
+
+                                return (
+                                    <LogInnerSpan
+                                        key={[log.lineNumber, i, j].join(".")}
+                                        ansi={ansi}
+                                    >
+                                        {ansi.content.replace(/ /g, "\u00a0")}
+                                    </LogInnerSpan>
+                                );
+                            })}
+                        </LogOuter>
+                    </Log>
+                );
+            })}
+        </StyledLogsContainer>
+    );
+};
+
+export default StyledLogs;
+
+const StyledLogsContainer = styled.div`
+`;
+
+const LogLabelsContainer = styled.div`
+    display: flex;
+    flex-direction: row;
+    align-items: center;
+    gap: 10px;
+`;
+
+const LineTimestamp = styled.span`
+    height: 100%;
+    color: #949effff;
+    opacity: 0.5;
+    font-family: monospace;
+    min-width: fit-content;
+`
+
+const Log = styled.div`
+  font-family: monospace;
+  user-select: text;
+  display: flex;
+  align-items: flex-start;
+  gap: 8px;
+  width: 100%;
+  min-height: 25px;
+`;
+
+const LogInnerPill = styled.div<{ color: string }>`
+    display: inline-block;
+    vertical-align: middle;
+    width: 100px;
+    padding: 0px 5px;
+    height: 20px;
+    color: black;
+    background-color: ${(props) => props.color};
+    border-radius: 5px;
+    opacity: 1;
+    font-family: monospace;
+    cursor: pointer;
+    hover: {
+        border: 1px solid #949effff;
+    }
+    overflow: hidden;
+    white-space: nowrap;    
+    text-overflow: ellipsis;
+`
+
+const LogOuter = styled.div`
+  display: inline-block;
+  word-wrap: anywhere;
+  flex-grow: 1;
+  font-family: monospace, sans-serif;
+  font-size: 12px;
+`;
+
+const LogInnerSpan = styled.span`
+  font-family: monospace, sans-serif;
+  font-size: 12px;
+  font-weight: ${(props: { ansi: Anser.AnserJsonEntry }) =>
+        props.ansi?.decoration && props.ansi?.decoration == "bold" ? "700" : "400"};
+  color: ${(props: { ansi: Anser.AnserJsonEntry }) =>
+        props.ansi?.fg ? `rgb(${props.ansi?.fg})` : "white"};
+  background-color: ${(props: { ansi: Anser.AnserJsonEntry }) =>
+        props.ansi?.bg ? `rgb(${props.ansi?.bg})` : "transparent"};
+`;

+ 43 - 1
dashboard/src/main/home/app-dashboard/expanded-app/logs/types.ts

@@ -10,6 +10,7 @@ export interface PorterLog {
     line: AnserJsonEntry[];
     lineNumber: number;
     timestamp?: string;
+    metadata?: z.infer<typeof AgentLogMetadataSchema>;
 }
 
 export interface PaginationInfo {
@@ -30,4 +31,45 @@ export const AgentLogSchema = z.object({
     timestamp: z.string(),
     metadata: AgentLogMetadataSchema.optional(),
 });
-export type AgentLog = z.infer<typeof AgentLogSchema>;
+export type AgentLog = z.infer<typeof AgentLogSchema>;
+
+export interface GenericFilterOption {
+    label: string;
+    value: string;
+}
+export const GenericFilterOption = {
+    of: (label: string, value: string): GenericFilterOption => {
+        return { label, value };
+    }
+}
+export type LogFilterName = 'revision' | 'output_stream' | 'pod_name';
+export interface GenericLogFilter {
+    name: LogFilterName;
+    displayName: string;
+    default: GenericFilterOption;
+    options: GenericFilterOption[];
+    setValue: (value: string) => void;
+}
+export const GenericLogFilter = {
+    isDefault: (filter: GenericLogFilter, value: string) => {
+        return filter.default.value === value;
+    },
+
+    getDefaultOption: (filterName: LogFilterName) => {
+        switch (filterName) {
+            case 'revision':
+                return GenericFilterOption.of('All', 'all');
+            case 'output_stream':
+                return GenericFilterOption.of('stdout', 'stdout');
+            case 'pod_name':
+                return GenericFilterOption.of('All', 'all');
+            default:
+                return GenericFilterOption.of('All', 'all');
+        }
+    },
+}
+export type LogFilterQueryParamOpts = {
+    revision: string | null;
+    output_stream: string | null;
+    service: string | null;
+}

+ 108 - 20
dashboard/src/main/home/app-dashboard/expanded-app/logs/utils.ts

@@ -6,7 +6,7 @@ import Anser from "anser";
 import { Context } from "shared/Context";
 import { useWebsockets, NewWebsocketOptions } from "shared/hooks/useWebsockets";
 import { ChartType } from "shared/types";
-import { AgentLog, AgentLogSchema, Direction, PorterLog, PaginationInfo } from "./types";
+import { AgentLog, AgentLogSchema, Direction, PorterLog, PaginationInfo, GenericLogFilter, LogFilterName } from "./types";
 
 const MAX_LOGS = 5000;
 const MAX_BUFFER_LOGS = 1000;
@@ -23,6 +23,7 @@ export const parseLogs = (logs: any[] = []): PorterLog[] => {
         line: ansiLog,
         lineNumber: idx + 1,
         timestamp: parsed.timestamp,
+        metadata: parsed.metadata,
       };
     } catch (err) {
       return {
@@ -35,8 +36,8 @@ export const parseLogs = (logs: any[] = []): PorterLog[] => {
 };
 
 export const useLogs = (
-  currentPodName: string,
-  currentPodType: string,
+  selectedFilterValues: Record<LogFilterName, string>,
+  appName: string,
   namespace: string,
   searchParam: string,
   notify: (message: string) => void,
@@ -47,11 +48,11 @@ export const useLogs = (
   timeRange?: {
     startTime?: Dayjs,
     endTime?: Dayjs,
-  }
+  },
 ) => {
   const isLive = !setDate;
   const logsBufferRef = useRef<PorterLog[]>([]);
-  const { currentCluster, currentProject, setCurrentError } = useContext(
+  const { currentCluster, currentProject } = useContext(
     Context
   );
   const [logs, setLogs] = useState<PorterLog[]>([]);
@@ -60,11 +61,9 @@ export const useLogs = (
     nextCursor: null,
   });
 
-  // if currentPodName is empty assume we are looking at all chart pod logs
-  const currentPod =
-    currentPodName == ""
-      ? currentChart?.name
-      : `${currentChart?.name}-${currentPodName}-${currentPodType}`;
+  // if currentPodName is default value we are looking at all chart pod logs
+  const currentPodSelector = selectedFilterValues.pod_name === GenericLogFilter.getDefaultOption("pod_name").value
+    ? `${currentChart?.name ?? ''}-.*` : `${currentChart?.name}-${selectedFilterValues.pod_name}-.*`;
 
   // if we are live:
   // - start date is initially set to 2 weeks ago
@@ -80,7 +79,6 @@ export const useLogs = (
   const {
     newWebsocket,
     openWebsocket,
-    closeWebsocket,
     closeAllWebsockets,
   } = useWebsockets();
 
@@ -95,7 +93,6 @@ export const useLogs = (
 
     setLogs((logs) => {
       let updatedLogs = _.cloneDeep(logs);
-
       /**
        * If direction = Direction.forward, we want to append the new logs
        * at the end of the current logs, else we want to append before the current logs
@@ -134,7 +131,7 @@ export const useLogs = (
         }
       }
 
-      return updatedLogs;
+      return filterLogs(updatedLogs);
     });
   };
 
@@ -160,14 +157,14 @@ export const useLogs = (
   };
 
   const setupWebsocket = (websocketKey: string) => {
-    if (namespace == "") {
+    if (namespace == "" || currentCluster == null || currentProject == null || currentChart == null) {
       return;
     }
 
     const websocketBaseURL = `/api/projects/${currentProject.id}/clusters/${currentCluster.id}/namespaces/${namespace}/logs/loki`;
 
     const q = new URLSearchParams({
-      pod_selector: currentPod + "-.*",
+      pod_selector: currentPodSelector,
       namespace,
       search_param: searchParam,
       revision: currentChart.version.toString(),
@@ -195,7 +192,9 @@ export const useLogs = (
             // console.log(err)
           }
         });
-        pushLogs(parseLogs(newLogs));
+        const newLogsParsed = parseLogs(newLogs);
+        const newLogsFiltered = filterLogs(newLogsParsed);
+        pushLogs(newLogsFiltered);
       },
       onclose: () => {
         console.log("Closed websocket:", websocketKey);
@@ -206,6 +205,31 @@ export const useLogs = (
     openWebsocket(websocketKey);
   };
 
+  const filterLogs = (logs: PorterLog[]) => {
+    return logs.filter(log => {
+      if (log.metadata == null) {
+        return true;
+      }
+
+      // TODO: refactor this extremely hacky way to filter out pre-deploy logs
+      if (!currentChart?.name.endsWith("-r") && log.metadata.pod_name.startsWith(`${appName}-r`)) {
+        return false;
+      }
+
+      if (selectedFilterValues.output_stream !== GenericLogFilter.getDefaultOption("output_stream").value &&
+        log.metadata.output_stream !== selectedFilterValues.output_stream) {
+        return false;
+      }
+
+      if (selectedFilterValues.revision !== GenericLogFilter.getDefaultOption("revision").value &&
+        log.metadata.revision !== selectedFilterValues.revision) {
+        return false;
+      }
+
+      return true;
+    });
+  };
+
   const queryLogs = async (
     startDate: string,
     endDate: string,
@@ -231,7 +255,7 @@ export const useLogs = (
       end_range: endDate,
       limit,
       chart_name: "",
-      pod_selector: currentPod + "-.*",
+      pod_selector: currentPodSelector,
       direction,
     };
 
@@ -290,7 +314,7 @@ export const useLogs = (
   };
 
   const refresh = async () => {
-    if (!currentPod) {
+    if (!currentPodSelector) {
       return;
     }
 
@@ -321,7 +345,7 @@ export const useLogs = (
 
     closeAllWebsockets();
     const suffix = Math.random().toString(36).substring(2, 15);
-    const websocketKey = `${currentPod}-${namespace}-websocket-${suffix}`;
+    const websocketKey = `${currentPodSelector}-${namespace}-websocket-${suffix}`;
 
     setLoading(false);
 
@@ -418,7 +442,7 @@ export const useLogs = (
 
   useEffect(() => {
     refresh();
-  }, [currentPod, namespace, searchParam, setDate]);
+  }, [currentPodSelector, namespace, searchParam, setDate, selectedFilterValues]);
 
   useEffect(() => {
     // if the streaming is no longer live, close all websockets
@@ -440,3 +464,67 @@ export const useLogs = (
     paginationInfo,
   };
 };
+
+export const getVersionTagColor = (version: string) => {
+  const colors = [
+    "#7B61FF",
+    "#FF7B61",
+    "#61FF7B",
+  ];
+
+  const versionInt = parseInt(version);
+  if (isNaN(versionInt)) {
+    return colors[0];
+  }
+  return colors[versionInt % colors.length];
+};
+
+export const getServiceNameFromPodNameAndAppName = (podName: string, porterAppName: string) => {
+  const prefix: string = porterAppName + "-";
+  if (!podName.startsWith(prefix)) {
+    return "";
+  }
+
+  podName = podName.replace(prefix, "");
+  const suffixes: string[] = ["-web", "-wkr", "-job"];
+  let index: number = -1;
+
+  for (const suffix of suffixes) {
+    const newIndex: number = podName.lastIndexOf(suffix);
+    if (newIndex > index) {
+      index = newIndex;
+    }
+  }
+
+  if (index !== -1) {
+    return podName.substring(0, index);
+  }
+
+  return "";
+}
+
+export const getPodSelectorFromPodNameAndAppName = (podName: string, porterAppName: string) => {
+  const prefix: string = porterAppName + "-";
+  if (!podName.startsWith(prefix)) {
+    return "";
+  }
+
+  podName = podName.replace(prefix, "");
+  const suffixes: string[] = ["-web", "-wkr", "-job"];
+  let index: number = -1;
+  let type = ""
+
+  for (const suffix of suffixes) {
+    const newIndex: number = podName.lastIndexOf(suffix);
+    if (newIndex > index) {
+      index = newIndex;
+      type = suffix;
+    }
+  }
+
+  if (index !== -1) {
+    return podName.substring(0, index) + type;
+  }
+
+  return "";
+}

+ 4 - 2
dashboard/src/shared/api.tsx

@@ -2467,6 +2467,7 @@ const updateStackStep = baseApi<
     step: string;
     stack_name?: string;
     error_message?: string;
+    delete_workflow_file?: boolean;
   },
   {
     project_id: number;
@@ -2657,9 +2658,10 @@ const createSecretAndOpenGitHubPullRequest = baseApi<
     github_app_installation_id: number;
     github_repo_owner: string;
     github_repo_name: string;
-    open_pr: boolean;
     branch: string;
-    porter_yaml_path: string;
+    open_pr?: boolean;
+    porter_yaml_path?: string;
+    delete_workflow_filename?: string;
   },
   {
     project_id: number;

+ 0 - 31
dashboard/src/shared/types.tsx

@@ -666,35 +666,4 @@ export interface CreateUpdatePorterAppOptions {
   full_helm_values?: string;
 }
 
-export enum PorterAppEventType {
-  BUILD = "BUILD",
-  DEPLOY = "DEPLOY",
-  APP_EVENT = "APP_EVENT",
-  PRE_DEPLOY = "PRE_DEPLOY",
-}
-export interface PorterAppEvent {
-  created_at: string;
-  updated_at: string;
-  id: string;
-  status: string;
-  type: PorterAppEventType;
-  type_source: string;
-  porter_app_id: number;
-  metadata: any;
-}
-export const PorterAppEvent = {
-  toPorterAppEvent: (data: any): PorterAppEvent => {
-    return {
-      created_at: data.created_at ?? "",
-      updated_at: data.updated_at ?? "",
-      id: data.id ?? "",
-      status: data.status ?? "",
-      type: data.type ?? "",
-      type_source: data.type_source ?? "",
-      porter_app_id: data.porter_app_id ?? "",
-      metadata: data.metadata ?? {},
-    };
-  }
-}
-
 

+ 7 - 5
internal/analytics/tracks.go

@@ -834,11 +834,12 @@ func StackLaunchFailureTrack(opts *StackLaunchFailureOpts) segmentTrack {
 type StackDeletionOpts struct {
 	*ProjectScopedTrackOpts
 
-	StackName   string
-	Email       string
-	FirstName   string
-	LastName    string
-	CompanyName string
+	StackName          string
+	Email              string
+	FirstName          string
+	LastName           string
+	CompanyName        string
+	DeleteWorkflowFile bool
 }
 
 // StackDeletionTrack returns a track for when a user deletes a stack
@@ -848,6 +849,7 @@ func StackDeletionTrack(opts *StackDeletionOpts) segmentTrack {
 	additionalProps["email"] = opts.Email
 	additionalProps["name"] = opts.FirstName + " " + opts.LastName
 	additionalProps["company"] = opts.CompanyName
+	additionalProps["delete_workflow_file"] = opts.DeleteWorkflowFile
 
 	return getSegmentProjectTrack(
 		opts.ProjectScopedTrackOpts,

+ 56 - 27
internal/integrations/ci/actions/stack.go

@@ -20,6 +20,7 @@ type GithubPROpts struct {
 	SecretName                string
 	PorterYamlPath            string
 	Body                      string
+	DeleteWorkflowFilename    string
 }
 
 type GetStackApplyActionYAMLOpts struct {
@@ -33,26 +34,19 @@ type GetStackApplyActionYAMLOpts struct {
 
 func OpenGithubPR(opts *GithubPROpts) (*github.PullRequest, error) {
 	var pr *github.PullRequest
-	applyWorkflowYAML, err := getStackApplyActionYAML(&GetStackApplyActionYAMLOpts{
-		ServerURL:      opts.ServerURL,
-		ClusterID:      opts.ClusterID,
-		ProjectID:      opts.ProjectID,
-		StackName:      opts.StackName,
-		DefaultBranch:  opts.DefaultBranch,
-		SecretName:     opts.SecretName,
-		PorterYamlPath: opts.PorterYamlPath,
-	})
-	if err != nil {
-		return pr, err
+	var prBranchName string
+	if opts.DeleteWorkflowFilename != "" {
+		prBranchName = "porter-stack-delete"
+	} else {
+		prBranchName = "porter-stack"
 	}
 
-	prBranchName := "porter-stack"
-
-	err = createNewBranch(opts.Client,
+	err := createNewBranch(opts.Client,
 		opts.GitRepoOwner,
 		opts.GitRepoName,
 		opts.DefaultBranch,
-		prBranchName)
+		prBranchName,
+	)
 	if err != nil {
 		return pr, fmt.Errorf(
 			"error creating branch: %w",
@@ -60,23 +54,58 @@ func OpenGithubPR(opts *GithubPROpts) (*github.PullRequest, error) {
 		)
 	}
 
-	_, err = commitWorkflowFile(
-		opts.Client,
-		fmt.Sprintf("porter_stack_%s.yml", strings.ToLower(opts.StackName)),
-		applyWorkflowYAML, opts.GitRepoOwner,
-		opts.GitRepoName, prBranchName, false,
-	)
-
-	if err != nil {
-		return pr, fmt.Errorf(
-			"error committing file: %w",
-			err,
+	if opts.DeleteWorkflowFilename == "" {
+		applyWorkflowYAML, err := getStackApplyActionYAML(&GetStackApplyActionYAMLOpts{
+			ServerURL:      opts.ServerURL,
+			ClusterID:      opts.ClusterID,
+			ProjectID:      opts.ProjectID,
+			StackName:      opts.StackName,
+			DefaultBranch:  opts.DefaultBranch,
+			SecretName:     opts.SecretName,
+			PorterYamlPath: opts.PorterYamlPath,
+		})
+		if err != nil {
+			return pr, err
+		}
+		_, err = commitWorkflowFile(
+			opts.Client,
+			fmt.Sprintf("porter_stack_%s.yml", strings.ToLower(opts.StackName)),
+			applyWorkflowYAML, opts.GitRepoOwner,
+			opts.GitRepoName, prBranchName, false,
+		)
+		if err != nil {
+			return pr, fmt.Errorf(
+				"error committing file: %w",
+				err,
+			)
+		}
+	} else {
+		err = deleteGithubFile(
+			opts.Client,
+			opts.DeleteWorkflowFilename,
+			opts.GitRepoOwner,
+			opts.GitRepoName,
+			prBranchName,
+			false,
 		)
+		if err != nil {
+			return pr, fmt.Errorf(
+				"error committing deletion: %w",
+				err,
+			)
+		}
+
 	}
 
+	var prTitle string
+	if opts.DeleteWorkflowFilename != "" {
+		prTitle = fmt.Sprintf("Delete Porter Application %s", opts.StackName)
+	} else {
+		prTitle = fmt.Sprintf("Enable Porter Application %s", opts.StackName)
+	}
 	pr, _, err = opts.Client.PullRequests.Create(
 		context.Background(), opts.GitRepoOwner, opts.GitRepoName, &github.NewPullRequest{
-			Title: github.String("Enable Porter Application"),
+			Title: github.String(prTitle),
 			Base:  github.String(opts.DefaultBranch),
 			Head:  github.String(prBranchName),
 			Body:  github.String(opts.Body),