Przeglądaj źródła

Add extra information to deploy events, supplemented by updates from porter-agent (#3352)

Feroze Mohideen 2 lat temu
rodzic
commit
3ff45434c5
30 zmienionych plików z 878 dodań i 206 usunięć
  1. 26 14
      api/server/handlers/cluster/detect_agent_installed.go
  2. 4 4
      api/server/handlers/porter_app/analytics.go
  3. 135 8
      api/server/handlers/porter_app/create.go
  4. 191 3
      api/server/handlers/porter_app/create_and_update_events.go
  5. 5 5
      api/server/handlers/porter_app/list_events.go
  6. 52 0
      api/server/handlers/porter_app/parse.go
  7. 8 1
      api/server/handlers/porter_app/rollback.go
  8. 53 0
      api/server/shared/features/features.go
  9. 25 1
      api/types/porter_app.go
  10. 1 0
      dashboard/src/components/AzureProvisionerSettings.tsx
  11. 1 0
      dashboard/src/components/ProvisionerSettings.tsx
  12. 1 1
      dashboard/src/components/porter/Icon.tsx
  13. 38 18
      dashboard/src/components/porter/Link.tsx
  14. 1 0
      dashboard/src/main/home/app-dashboard/build-settings/ProviderSelector.tsx
  15. 18 17
      dashboard/src/main/home/app-dashboard/expanded-app/ExpandedApp.tsx
  16. 1 0
      dashboard/src/main/home/app-dashboard/expanded-app/PorterAppRevisionSection.tsx
  17. 13 10
      dashboard/src/main/home/app-dashboard/expanded-app/StatusFooter.tsx
  18. 10 2
      dashboard/src/main/home/app-dashboard/expanded-app/activity-feed/ActivityFeed.tsx
  19. 17 16
      dashboard/src/main/home/app-dashboard/expanded-app/activity-feed/events/cards/AppEventCard.tsx
  20. 4 4
      dashboard/src/main/home/app-dashboard/expanded-app/activity-feed/events/cards/BuildEventCard.tsx
  21. 88 96
      dashboard/src/main/home/app-dashboard/expanded-app/activity-feed/events/cards/DeployEventCard.tsx
  22. 3 2
      dashboard/src/main/home/app-dashboard/expanded-app/activity-feed/events/cards/EventCard.tsx
  23. 126 0
      dashboard/src/main/home/app-dashboard/expanded-app/activity-feed/events/cards/ServiceStatusDetail.tsx
  24. 7 1
      dashboard/src/main/home/app-dashboard/expanded-app/activity-feed/events/types.ts
  25. 15 0
      dashboard/src/main/home/app-dashboard/expanded-app/activity-feed/events/utils.ts
  26. 2 3
      dashboard/src/main/home/app-dashboard/expanded-app/expanded-job/ExpandedJob.tsx
  27. 3 0
      dashboard/src/main/home/app-dashboard/expanded-app/logs/LogSection.tsx
  28. 25 0
      internal/repository/gorm/porter_app_event.go
  29. 1 0
      internal/repository/porter_app_event.go
  30. 4 0
      internal/repository/test/porter_app_event.go

+ 26 - 14
api/server/handlers/cluster/detect_agent_installed.go

@@ -13,6 +13,7 @@ import (
 	"github.com/porter-dev/porter/api/types"
 	"github.com/porter-dev/porter/internal/kubernetes"
 	"github.com/porter-dev/porter/internal/models"
+	"github.com/porter-dev/porter/internal/telemetry"
 	v1 "k8s.io/api/apps/v1"
 )
 
@@ -32,31 +33,29 @@ func NewDetectAgentInstalledHandler(
 }
 
 func (c *DetectAgentInstalledHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
-	cluster, _ := r.Context().Value(types.ClusterScope).(*models.Cluster)
+	ctx := r.Context()
+	ctx, span := telemetry.NewSpan(ctx, "detect-agent-installed")
+	defer span.End()
 
+	cluster, _ := ctx.Value(types.ClusterScope).(*models.Cluster)
 	agent, err := c.GetAgent(r, cluster, "")
 	if err != nil {
-		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		err = telemetry.Error(ctx, span, err, "failed to get k8s agent")
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
 		return
 	}
 
-	depl, err := agent.GetPorterAgent()
-
-	if targetErr := kubernetes.IsNotFoundError; err != nil && errors.Is(err, targetErr) {
-		http.NotFound(w, r)
+	res, err := GetAgentVersionResponse(agent)
+	if err != nil && errors.Is(err, kubernetes.IsNotFoundError) {
+		err = telemetry.Error(ctx, span, err, "porter agent not found")
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusNotFound))
 		return
 	} else if err != nil {
-		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		err = telemetry.Error(ctx, span, err, "porter agent not found")
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
 		return
 	}
 
-	// detect the version of the agent which is installed
-	res := &types.DetectAgentResponse{
-		Version:       getAgentVersionFromDeployment(depl),
-		ShouldUpgrade: false,
-		Image:         getImageFromDeployment(depl),
-	}
-
 	if res.Version != "v3" {
 		res.ShouldUpgrade = true
 	}
@@ -66,6 +65,19 @@ func (c *DetectAgentInstalledHandler) ServeHTTP(w http.ResponseWriter, r *http.R
 	c.WriteResult(w, r, res)
 }
 
+func GetAgentVersionResponse(agent *kubernetes.Agent) (*types.DetectAgentResponse, error) {
+	depl, err := agent.GetPorterAgent()
+	if err != nil {
+		return nil, err
+	}
+
+	return &types.DetectAgentResponse{
+		Version:       getAgentVersionFromDeployment(depl),
+		ShouldUpgrade: false,
+		Image:         getImageFromDeployment(depl),
+	}, nil
+}
+
 func getAgentVersionFromDeployment(depl *v1.Deployment) string {
 	versionAnn := depl.ObjectMeta.Annotations["porter.run/agent-major-version"]
 

+ 4 - 4
api/server/handlers/porter_app/analytics.go

@@ -100,9 +100,9 @@ func TrackStackBuildStatus(
 	project *models.Project,
 	stackName string,
 	errorMessage string,
-	status string,
+	status types.PorterAppEventStatus,
 ) error {
-	if status == "PROGRESSING" {
+	if status == types.PorterAppEventStatus_Progressing {
 		return config.AnalyticsClient.Track(analytics.StackBuildProgressingTrack(&analytics.StackBuildOpts{
 			ProjectScopedTrackOpts: analytics.GetProjectScopedTrackOpts(user.ID, project.ID),
 			StackName:              stackName,
@@ -113,7 +113,7 @@ func TrackStackBuildStatus(
 		}))
 	}
 
-	if status == "SUCCESS" {
+	if status == types.PorterAppEventStatus_Success {
 		return config.AnalyticsClient.Track(analytics.StackBuildSuccessTrack(&analytics.StackBuildOpts{
 			ProjectScopedTrackOpts: analytics.GetProjectScopedTrackOpts(user.ID, project.ID),
 			StackName:              stackName,
@@ -124,7 +124,7 @@ func TrackStackBuildStatus(
 		}))
 	}
 
-	if status == "FAILED" {
+	if status == types.PorterAppEventStatus_Failed {
 		return config.AnalyticsClient.Track(analytics.StackBuildFailureTrack(&analytics.StackBuildOpts{
 			ProjectScopedTrackOpts: analytics.GetProjectScopedTrackOpts(user.ID, project.ID),
 			StackName:              stackName,

+ 135 - 8
api/server/handlers/porter_app/create.go

@@ -3,6 +3,7 @@ package porter_app
 import (
 	"context"
 	"encoding/base64"
+	"encoding/json"
 	"errors"
 	"fmt"
 	"net/http"
@@ -18,6 +19,7 @@ import (
 	"github.com/porter-dev/porter/api/server/shared"
 	"github.com/porter-dev/porter/api/server/shared/apierrors"
 	"github.com/porter-dev/porter/api/server/shared/config"
+	"github.com/porter-dev/porter/api/server/shared/features"
 	"github.com/porter-dev/porter/api/server/shared/requestutils"
 	"github.com/porter-dev/porter/api/types"
 	utils "github.com/porter-dev/porter/api/utils/porter_app"
@@ -48,6 +50,7 @@ func (c *CreatePorterAppHandler) ServeHTTP(w http.ResponseWriter, r *http.Reques
 	ctx := r.Context()
 	project, _ := ctx.Value(types.ProjectScope).(*models.Project)
 	cluster, _ := ctx.Value(types.ClusterScope).(*models.Cluster)
+	user, _ := ctx.Value(types.UserScope).(*models.User)
 
 	ctx, span := telemetry.NewSpan(r.Context(), "serve-create-porter-app")
 	defer span.End()
@@ -205,7 +208,7 @@ func (c *CreatePorterAppHandler) ServeHTTP(w http.ResponseWriter, r *http.Reques
 		// create the release job chart if it does not exist (only done by front-end currently, where we set overrideRelease=true)
 		if request.OverrideRelease && preDeployJobValues != nil {
 			telemetry.WithAttributes(span, telemetry.AttributeKV{Key: "installing-pre-deploy-job", Value: true})
-			conf, err := createReleaseJobChart(
+			conf, err := createPreDeployJobChart(
 				ctx,
 				appName,
 				preDeployJobValues,
@@ -299,7 +302,12 @@ func (c *CreatePorterAppHandler) ServeHTTP(w http.ResponseWriter, r *http.Reques
 			return
 		}
 
-		_, err = createPorterAppEvent(ctx, "SUCCESS", porterApp.ID, 1, imageInfo.Tag, c.Repo().PorterAppEvent())
+		if features.AreAgentDeployEventsEnabled(user.Email, k8sAgent) {
+			serviceDeploymentStatusMap := getServiceDeploymentMetadataFromValues(values, types.PorterAppEventStatus_Progressing)
+			_, err = createNewPorterAppDeployEvent(ctx, serviceDeploymentStatusMap, types.PorterAppEventStatus_Progressing, porterApp.ID, 1, imageInfo.Tag, c.Repo().PorterAppEvent())
+		} else {
+			_, err = createOldPorterAppDeployEvent(ctx, types.PorterAppEventStatus_Success, porterApp.ID, 1, imageInfo.Tag, c.Repo().PorterAppEvent())
+		}
 		if err != nil {
 			err = telemetry.Error(ctx, span, err, "error creating porter app event")
 			telemetry.WithAttributes(span, telemetry.AttributeKV{Key: "porter-yaml-base64", Value: porterYamlBase64})
@@ -332,7 +340,7 @@ func (c *CreatePorterAppHandler) ServeHTTP(w http.ResponseWriter, r *http.Reques
 				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(
+					conf, err := createPreDeployJobChart(
 						ctx,
 						appName,
 						preDeployJobValues,
@@ -483,7 +491,12 @@ func (c *CreatePorterAppHandler) ServeHTTP(w http.ResponseWriter, r *http.Reques
 			return
 		}
 
-		_, err = createPorterAppEvent(ctx, "SUCCESS", updatedPorterApp.ID, helmRelease.Version+1, imageInfo.Tag, c.Repo().PorterAppEvent())
+		if features.AreAgentDeployEventsEnabled(user.Email, k8sAgent) {
+			serviceDeploymentStatusMap := getServiceDeploymentMetadataFromValues(values, types.PorterAppEventStatus_Progressing)
+			_, err = createNewPorterAppDeployEvent(ctx, serviceDeploymentStatusMap, types.PorterAppEventStatus_Progressing, updatedPorterApp.ID, helmRelease.Version+1, imageInfo.Tag, c.Repo().PorterAppEvent())
+		} else {
+			_, err = createOldPorterAppDeployEvent(ctx, types.PorterAppEventStatus_Success, updatedPorterApp.ID, helmRelease.Version+1, imageInfo.Tag, c.Repo().PorterAppEvent())
+		}
 		if err != nil {
 			err = telemetry.Error(ctx, span, err, "error creating porter app event")
 			telemetry.WithAttributes(span, telemetry.AttributeKV{Key: "porter-yaml-base64", Value: porterYamlBase64})
@@ -495,11 +508,15 @@ func (c *CreatePorterAppHandler) ServeHTTP(w http.ResponseWriter, r *http.Reques
 	}
 }
 
-// createPorterAppEvent creates an event for use in the activity feed
-func createPorterAppEvent(ctx context.Context, status string, appID uint, revision int, tag string, repo repository.PorterAppEventRepository) (*models.PorterAppEvent, error) {
+// createOldPorterAppDeployEvent creates an event for use in the activity feed
+// TODO: remove this method and all call-sites if this span no longer exists in telemetry for 4 consecutive weeks
+func createOldPorterAppDeployEvent(ctx context.Context, status types.PorterAppEventStatus, appID uint, revision int, tag string, repo repository.PorterAppEventRepository) (*models.PorterAppEvent, error) {
+	ctx, span := telemetry.NewSpan(ctx, "create-old-porter-app-deploy-event")
+	defer span.End()
+
 	event := models.PorterAppEvent{
 		ID:                 uuid.New(),
-		Status:             status,
+		Status:             string(status),
 		Type:               "DEPLOY",
 		TypeExternalSource: "KUBERNETES",
 		PorterAppID:        appID,
@@ -521,7 +538,117 @@ func createPorterAppEvent(ctx context.Context, status string, appID uint, revisi
 	return &event, nil
 }
 
-func createReleaseJobChart(
+// createNewPorterAppDeployEvent creates an event for use in the activity feed, supplemented with information about the
+// deployed services in serviceStatusMap as well as the image tag being deployed
+func createNewPorterAppDeployEvent(
+	ctx context.Context,
+	serviceStatusMap map[string]types.ServiceDeploymentMetadata,
+	status types.PorterAppEventStatus,
+	appID uint,
+	revision int,
+	tag string,
+	repo repository.PorterAppEventRepository,
+) (*models.PorterAppEvent, error) {
+	ctx, span := telemetry.NewSpan(ctx, "create-new-porter-app-deploy-event")
+	defer span.End()
+
+	// mark all pending deployments from the deploy event of the previous revision as canceled
+	updatePreviousPorterAppDeployEvent(ctx, appID, revision, repo)
+
+	event := models.PorterAppEvent{
+		ID:                 uuid.New(),
+		Status:             string(status),
+		Type:               "DEPLOY",
+		TypeExternalSource: "KUBERNETES",
+		PorterAppID:        appID,
+		Metadata: map[string]any{
+			"revision":                    revision,
+			"image_tag":                   tag,
+			"service_deployment_metadata": serviceStatusMap,
+		},
+	}
+
+	telemetry.WithAttributes(span, telemetry.AttributeKV{Key: "revision", Value: revision}, telemetry.AttributeKV{Key: "image-tag", Value: tag})
+
+	err := repo.CreateEvent(ctx, &event)
+	if err != nil {
+		err = telemetry.Error(ctx, span, err, "error creating porter app event")
+		return nil, err
+	}
+
+	if event.ID == uuid.Nil {
+		return nil, telemetry.Error(ctx, span, nil, "event id for newly created app event is nil")
+	}
+
+	return &event, nil
+}
+
+// updatePreviousPorterAppDeployEvent updates the previous deploy event to change the event status as well as all service statuses to CANCELED
+// if it is still in the PROGRESSING state. This is done to prevent the activity feed from showing an old deploy event as still in progress.
+func updatePreviousPorterAppDeployEvent(ctx context.Context, appID uint, revision int, repo repository.PorterAppEventRepository) {
+	ctx, span := telemetry.NewSpan(ctx, "update-previous-porter-app-deploy-event")
+	defer span.End()
+
+	telemetry.WithAttributes(span, telemetry.AttributeKV{Key: "updating-previous-event", Value: false}, telemetry.AttributeKV{Key: "new-revision", Value: revision})
+	if revision <= 1 {
+		return
+	}
+	revisionFloat64 := float64(revision - 1)
+	matchEvent, err := repo.ReadDeployEventByRevision(ctx, appID, revisionFloat64)
+	if err != nil {
+		_ = telemetry.Error(ctx, span, err, "error reading deploy event by revision")
+		return
+	}
+	if matchEvent.ID == uuid.Nil {
+		_ = telemetry.Error(ctx, span, nil, "could not find previous deploy event")
+		return
+	}
+	if matchEvent.Status != string(types.PorterAppEventStatus_Progressing) {
+		return
+	}
+	serviceStatus, ok := matchEvent.Metadata["service_deployment_metadata"]
+	if !ok {
+		_ = telemetry.Error(ctx, span, nil, "service deployment metadata not found in deploy event metadata")
+		return
+	}
+	serviceDeploymentGenericMap, ok := serviceStatus.(map[string]interface{})
+	if !ok {
+		_ = telemetry.Error(ctx, span, nil, "service deployment metadata is not map[string]interface{}")
+		return
+	}
+	serviceDeploymentMap := make(map[string]types.ServiceDeploymentMetadata)
+	for k, v := range serviceDeploymentGenericMap {
+		by, err := json.Marshal(v)
+		if err != nil {
+			_ = telemetry.Error(ctx, span, nil, "unable to marshal")
+			return
+		}
+
+		var serviceDeploymentMetadata types.ServiceDeploymentMetadata
+		err = json.Unmarshal(by, &serviceDeploymentMetadata)
+		if err != nil {
+			_ = telemetry.Error(ctx, span, nil, "unable to unmarshal")
+			return
+		}
+		serviceDeploymentMap[k] = serviceDeploymentMetadata
+	}
+	for key, serviceDeploymentMetadata := range serviceDeploymentMap {
+		if serviceDeploymentMetadata.Status == types.PorterAppEventStatus_Progressing {
+			serviceDeploymentMetadata.Status = types.PorterAppEventStatus_Canceled
+			serviceDeploymentMap[key] = serviceDeploymentMetadata
+		}
+	}
+	matchEvent.Metadata["service_deployment_metadata"] = serviceDeploymentMap
+	matchEvent.Status = string(types.PorterAppEventStatus_Canceled)
+	err = repo.UpdateEvent(ctx, &matchEvent)
+	if err != nil {
+		_ = telemetry.Error(ctx, span, err, "error updating deploy event")
+		return
+	}
+	telemetry.WithAttributes(span, telemetry.AttributeKV{Key: "updating-previous-event", Value: true})
+}
+
+func createPreDeployJobChart(
 	ctx context.Context,
 	stackName string,
 	values map[string]interface{},

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

@@ -2,6 +2,7 @@ package porter_app
 
 import (
 	"context"
+	"encoding/json"
 	"fmt"
 	"net/http"
 	"strings"
@@ -115,7 +116,7 @@ func reportBuildStatus(ctx context.Context, request *types.CreateOrUpdatePorterA
 }
 
 // createNewAppEvent will create a new app event for the given porter app name. If the app event is an agent event, then it will be created only if there is no existing event which has the agent ID. In the case that an existing event is found, that will be returned instead
-func (p *CreateUpdatePorterAppEventHandler) createNewAppEvent(ctx context.Context, cluster models.Cluster, porterAppName string, status string, eventType string, externalSource string, requestMetadata map[string]any) (types.PorterAppEvent, error) {
+func (p *CreateUpdatePorterAppEventHandler) createNewAppEvent(ctx context.Context, cluster models.Cluster, porterAppName string, status types.PorterAppEventStatus, eventType string, externalSource string, requestMetadata map[string]any) (types.PorterAppEvent, error) {
 	ctx, span := telemetry.NewSpan(ctx, "create-porter-app-event")
 	defer span.End()
 
@@ -159,9 +160,16 @@ func (p *CreateUpdatePorterAppEventHandler) createNewAppEvent(ctx context.Contex
 		}
 	}
 
+	if eventType == string(types.PorterAppEventType_Deploy) {
+		// Agent has no way to know what the porter app event id is, so update the deploy event if it exists
+		if _, ok := requestMetadata["deploy_status"]; ok {
+			return p.updateDeployEvent(ctx, porterAppName, app.ID, requestMetadata), nil
+		}
+	}
+
 	event := models.PorterAppEvent{
 		ID:                 uuid.New(),
-		Status:             status,
+		Status:             string(status),
 		Type:               eventType,
 		TypeExternalSource: externalSource,
 		PorterAppID:        app.ID,
@@ -213,7 +221,7 @@ func (p *CreateUpdatePorterAppEventHandler) updateExistingAppEvent(ctx context.C
 	}
 
 	if submittedEvent.Status != "" {
-		existingAppEvent.Status = submittedEvent.Status
+		existingAppEvent.Status = string(submittedEvent.Status)
 	}
 
 	if submittedEvent.Metadata != nil {
@@ -229,3 +237,183 @@ func (p *CreateUpdatePorterAppEventHandler) updateExistingAppEvent(ctx context.C
 
 	return existingAppEvent.ToPorterAppEvent(), nil
 }
+
+// updateDeployEvent attempts to update the deploy event with the deploy status of each service given in updatedStatusMetadata
+// an update is only made in the following cases:
+// 1. the deploy event is found
+// 2. the deploy event is in the PROGRESSING state
+// 3. the deploy event service deployment metadata is formatted correctly
+// 4. the services specified in the updatedStatusMetadata match the services in the deploy event metadata
+// 5. some of the above services are still in the PROGRESSING state
+// if one of these conditions is not met, then an empty event is returned and no update is made; otherwise, the matched event is returned
+func (p *CreateUpdatePorterAppEventHandler) updateDeployEvent(ctx context.Context, appName string, appID uint, updatedStatusMetadata map[string]any) types.PorterAppEvent {
+	ctx, span := telemetry.NewSpan(ctx, "update-deploy-event")
+	defer span.End()
+
+	revision, ok := updatedStatusMetadata["revision"]
+	if !ok {
+		_ = telemetry.Error(ctx, span, nil, "revision not found in request metadata")
+		return types.PorterAppEvent{}
+	}
+	revisionFloat64, ok := revision.(float64)
+	if !ok {
+		_ = telemetry.Error(ctx, span, nil, "revision not a float64")
+		return types.PorterAppEvent{}
+	}
+	telemetry.WithAttributes(span, telemetry.AttributeKV{Key: "revision", Value: revisionFloat64})
+
+	podName, ok := updatedStatusMetadata["pod_name"]
+	if !ok {
+		_ = telemetry.Error(ctx, span, nil, "pod name not found in request metadata")
+		return types.PorterAppEvent{}
+	}
+	podNameStr, ok := podName.(string)
+	if !ok {
+		_ = telemetry.Error(ctx, span, nil, "pod name not a string")
+		return types.PorterAppEvent{}
+	}
+	telemetry.WithAttributes(span, telemetry.AttributeKV{Key: "pod-name", Value: podNameStr})
+
+	serviceName := getServiceNameFromPodName(podNameStr, appName)
+	if serviceName == "" {
+		_ = telemetry.Error(ctx, span, nil, "service name not found in pod name")
+		return types.PorterAppEvent{}
+	}
+	telemetry.WithAttributes(span, telemetry.AttributeKV{Key: "service-name", Value: serviceName})
+
+	newStatus, ok := updatedStatusMetadata["deploy_status"]
+	if !ok {
+		_ = telemetry.Error(ctx, span, nil, "deploy status not found in request metadata")
+		return types.PorterAppEvent{}
+	}
+	newStatusStr, ok := newStatus.(string)
+	if !ok {
+		_ = telemetry.Error(ctx, span, nil, "deploy status not a string")
+		return types.PorterAppEvent{}
+	}
+	var porterAppEventStatus types.PorterAppEventStatus
+	switch newStatusStr {
+	case string(types.PorterAppEventStatus_Success):
+		porterAppEventStatus = types.PorterAppEventStatus_Success
+	case string(types.PorterAppEventStatus_Failed):
+		porterAppEventStatus = types.PorterAppEventStatus_Failed
+	case string(types.PorterAppEventStatus_Progressing):
+		porterAppEventStatus = types.PorterAppEventStatus_Progressing
+	default:
+		_ = telemetry.Error(ctx, span, nil, "deploy status not valid")
+		return types.PorterAppEvent{}
+	}
+
+	telemetry.WithAttributes(span, telemetry.AttributeKV{Key: "new-status", Value: string(porterAppEventStatus)})
+
+	matchEvent, err := p.Repo().PorterAppEvent().ReadDeployEventByRevision(ctx, appID, revisionFloat64)
+	if err != nil {
+		_ = telemetry.Error(ctx, span, err, "error finding matching deploy event")
+		return types.PorterAppEvent{}
+	}
+
+	telemetry.WithAttributes(span, telemetry.AttributeKV{Key: "updating-deployment-event", Value: false})
+
+	// first check to see if the event is empty, meaning there was no match found, or not progressing, meaning it has already been updated
+	if matchEvent.ID == uuid.Nil || matchEvent.Status != string(types.PorterAppEventStatus_Progressing) {
+		return types.PorterAppEvent{}
+	}
+
+	serviceStatus, ok := matchEvent.Metadata["service_deployment_metadata"]
+	if !ok {
+		_ = telemetry.Error(ctx, span, nil, "service deployment metadata not found in deploy event metadata")
+		return types.PorterAppEvent{}
+	}
+	serviceDeploymentGenericMap, ok := serviceStatus.(map[string]interface{})
+	if !ok {
+		_ = telemetry.Error(ctx, span, nil, "service deployment metadata is not map[string]interface{}")
+		return types.PorterAppEvent{}
+	}
+	serviceDeploymentMap := make(map[string]types.ServiceDeploymentMetadata)
+	for k, v := range serviceDeploymentGenericMap {
+		by, err := json.Marshal(v)
+		if err != nil {
+			_ = telemetry.Error(ctx, span, nil, "unable to marshal")
+			return types.PorterAppEvent{}
+		}
+
+		var serviceDeploymentMetadata types.ServiceDeploymentMetadata
+		err = json.Unmarshal(by, &serviceDeploymentMetadata)
+		if err != nil {
+			_ = telemetry.Error(ctx, span, nil, "unable to unmarshal")
+			return types.PorterAppEvent{}
+		}
+		serviceDeploymentMap[k] = serviceDeploymentMetadata
+	}
+	serviceDeploymentMetadata, ok := serviceDeploymentMap[serviceName]
+	if !ok {
+		_ = telemetry.Error(ctx, span, nil, "deployment metadata not found for service")
+		return types.PorterAppEvent{}
+	}
+
+	telemetry.WithAttributes(span, telemetry.AttributeKV{Key: "existing-status", Value: serviceDeploymentMetadata.Status})
+
+	// only update service status if it has not been updated yet
+	if serviceDeploymentMetadata.Status == types.PorterAppEventStatus_Progressing {
+		// update the map with the new status
+		serviceDeploymentMetadata.Status = porterAppEventStatus
+		serviceDeploymentMap[serviceName] = serviceDeploymentMetadata
+
+		// update the deploy event with new map and status if all services are done
+		// note: this assumes that all services are reported 'done' sequentially
+		// if two service statuses are updated at the same time, we might miss updating the parent deploy event
+		matchEvent.Metadata["service_deployment_metadata"] = serviceDeploymentMap
+		allServicesDone := true
+		anyServicesFailed := false
+		for _, deploymentMetadata := range serviceDeploymentMap {
+			if deploymentMetadata.Status == types.PorterAppEventStatus_Progressing {
+				allServicesDone = false
+				break
+			}
+			if deploymentMetadata.Status == types.PorterAppEventStatus_Failed {
+				anyServicesFailed = true
+			}
+		}
+		if allServicesDone {
+			if anyServicesFailed {
+				matchEvent.Status = string(types.PorterAppEventStatus_Failed)
+			} else {
+				matchEvent.Status = string(types.PorterAppEventStatus_Success)
+			}
+		}
+
+		err = p.Repo().PorterAppEvent().UpdateEvent(ctx, &matchEvent)
+		if err != nil {
+			_ = telemetry.Error(ctx, span, err, "error updating deploy event")
+			return matchEvent.ToPorterAppEvent()
+		}
+		telemetry.WithAttributes(span, telemetry.AttributeKV{Key: "updating-deployment-event", Value: true})
+		return matchEvent.ToPorterAppEvent()
+	}
+
+	return types.PorterAppEvent{}
+}
+
+func getServiceNameFromPodName(podName, porterAppName string) string {
+	prefix := porterAppName + "-"
+	if !strings.HasPrefix(podName, prefix) {
+		return ""
+	}
+
+	podName = strings.TrimPrefix(podName, prefix)
+	suffixes := []string{"-web", "-wkr", "-job"}
+	index := -1
+
+	for _, suffix := range suffixes {
+		newIndex := strings.LastIndex(podName, suffix)
+		if newIndex > index {
+			index = newIndex
+		}
+	}
+
+	if index != -1 {
+		return podName[:index]
+	}
+
+	return ""
+}

+ 5 - 5
api/server/handlers/porter_app/list_events.go

@@ -77,7 +77,7 @@ func (p *PorterAppEventListHandler) ServeHTTP(w http.ResponseWriter, r *http.Req
 	}
 
 	for idx, appEvent := range porterAppEvents {
-		if appEvent.Status == "PROGRESSING" {
+		if appEvent.Status == string(types.PorterAppEventStatus_Progressing) {
 			pae, err := p.updateExistingAppEvent(ctx, *cluster, appName, *appEvent, user, project)
 			if err != nil {
 				telemetry.Error(ctx, span, nil, "unable to update existing porter app event")
@@ -219,11 +219,11 @@ func (p *PorterAppEventListHandler) updateBuildEvent_Github(
 
 	if *actionRun.Status == "completed" {
 		if *actionRun.Conclusion == "success" {
-			event.Status = "SUCCESS"
-			_ = TrackStackBuildStatus(p.Config(), user, project, stackName, "", "SUCCESS")
+			event.Status = string(types.PorterAppEventStatus_Success)
+			_ = TrackStackBuildStatus(p.Config(), user, project, stackName, "", types.PorterAppEventStatus_Success)
 		} else {
-			event.Status = "FAILED"
-			_ = TrackStackBuildStatus(p.Config(), user, project, stackName, "", "FAILED")
+			event.Status = string(types.PorterAppEventStatus_Failed)
+			_ = TrackStackBuildStatus(p.Config(), user, project, stackName, "", types.PorterAppEventStatus_Failed)
 		}
 		event.Metadata["end_time"] = actionRun.GetUpdatedAt().Time
 	}

+ 52 - 0
api/server/handlers/porter_app/parse.go

@@ -944,3 +944,55 @@ func addLabelsToService(service *Service, envGroups []string, defaultLabelKey st
 
 	return service
 }
+
+func getServiceDeploymentMetadataFromValues(values map[string]interface{}, status types.PorterAppEventStatus) map[string]types.ServiceDeploymentMetadata {
+	serviceDeploymentMap := make(map[string]types.ServiceDeploymentMetadata)
+
+	for key := range values {
+		if key != "global" {
+			serviceName, serviceType := getServiceNameAndTypeFromHelmName(key)
+			externalURI := getServiceExternalURIFromServiceValues(values[key].(map[string]interface{}))
+			// jobs don't technically have a deployment, so hardcode the deployment status to success
+			serviceStatus := status
+			if serviceType == "job" {
+				serviceStatus = types.PorterAppEventStatus_Success
+			}
+			serviceDeploymentMap[serviceName] = types.ServiceDeploymentMetadata{
+				ExternalURI: externalURI,
+				Status:      serviceStatus,
+				Type:        serviceType,
+			}
+		}
+	}
+	return serviceDeploymentMap
+}
+
+func getServiceExternalURIFromServiceValues(serviceValues map[string]interface{}) string {
+	ingressMap, err := getNestedMap(serviceValues, "ingress")
+	if err == nil {
+		enabledVal, enabledExists := ingressMap["enabled"]
+		if enabledExists {
+			enabled, eOK := enabledVal.(bool)
+			if eOK && enabled {
+				customDomVal, customDomExists := ingressMap["custom_domain"]
+				if customDomExists {
+					customDomain, cOK := customDomVal.(bool)
+					if cOK && customDomain {
+						hostsExists, hostsExistsOK := ingressMap["hosts"]
+						if hostsExistsOK {
+							if hosts, hostsOK := hostsExists.([]interface{}); hostsOK && len(hosts) == 1 {
+								return hosts[0].(string)
+							}
+						}
+					}
+				}
+
+				if porterHosts, ok := ingressMap["porter_hosts"].([]interface{}); ok && len(porterHosts) == 1 {
+					return porterHosts[0].(string)
+				}
+			}
+		}
+	}
+
+	return ""
+}

+ 8 - 1
api/server/handlers/porter_app/rollback.go

@@ -9,6 +9,7 @@ import (
 	"github.com/porter-dev/porter/api/server/shared"
 	"github.com/porter-dev/porter/api/server/shared/apierrors"
 	"github.com/porter-dev/porter/api/server/shared/config"
+	"github.com/porter-dev/porter/api/server/shared/features"
 	"github.com/porter-dev/porter/api/server/shared/requestutils"
 	"github.com/porter-dev/porter/api/types"
 	utils "github.com/porter-dev/porter/api/utils/porter_app"
@@ -38,6 +39,7 @@ func (c *RollbackPorterAppHandler) ServeHTTP(w http.ResponseWriter, r *http.Requ
 	ctx, span := telemetry.NewSpan(r.Context(), "serve-rollback-porter-app")
 	defer span.End()
 	cluster, _ := ctx.Value(types.ClusterScope).(*models.Cluster)
+	user, _ := ctx.Value(types.UserScope).(*models.User)
 
 	request := &types.RollbackPorterAppRequest{}
 	if ok := c.DecodeAndValidate(w, r, request); !ok {
@@ -152,7 +154,12 @@ func (c *RollbackPorterAppHandler) ServeHTTP(w http.ResponseWriter, r *http.Requ
 		return
 	}
 
-	_, err = createPorterAppEvent(ctx, "SUCCESS", porterApp.ID, latestHelmRelease.Version+1, imageInfo.Tag, c.Repo().PorterAppEvent())
+	if features.AreAgentDeployEventsEnabled(user.Email, k8sAgent) {
+		serviceDeploymentStatusMap := getServiceDeploymentMetadataFromValues(values, types.PorterAppEventStatus_Progressing)
+		_, err = createNewPorterAppDeployEvent(ctx, serviceDeploymentStatusMap, types.PorterAppEventStatus_Progressing, porterApp.ID, latestHelmRelease.Version+1, imageInfo.Tag, c.Repo().PorterAppEvent())
+	} else {
+		_, err = createOldPorterAppDeployEvent(ctx, types.PorterAppEventStatus_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))

+ 53 - 0
api/server/shared/features/features.go

@@ -0,0 +1,53 @@
+package features
+
+import (
+	"strconv"
+	"strings"
+
+	"github.com/porter-dev/porter/api/server/handlers/cluster"
+	"github.com/porter-dev/porter/internal/kubernetes"
+)
+
+// isPorterAgentUpdated checks if the agent version is at least the version specified by the major, minor, and patch arguments
+func isPorterAgentUpdated(agent *kubernetes.Agent, major, minor, patch int) bool {
+	res, err := cluster.GetAgentVersionResponse(agent)
+	if err != nil {
+		return false
+	}
+	image := res.Image
+	parsed := strings.Split(image, ":")
+
+	if len(parsed) != 2 {
+		return false
+	}
+
+	tag := parsed[1]
+	if tag == "dev" {
+		return true
+	}
+
+	parsedTag := strings.Split(tag, ".")
+	if len(parsedTag) != 3 {
+		return false
+	}
+
+	parsedMajor, _ := strconv.Atoi(parsedTag[0])
+	parsedMinor, _ := strconv.Atoi(parsedTag[1])
+	parsedPatch, _ := strconv.Atoi(parsedTag[2])
+	if parsedMajor < major {
+		return false
+	}
+	if parsedMinor < minor {
+		return false
+	}
+	if parsedPatch < patch {
+		return false
+	}
+	return true
+}
+
+// Only create the PROGRESSING event if the cluster's agent is updated, because only the updated agent can update the status
+// TODO: remove dependence on porter email once we are ready to release this feature
+func AreAgentDeployEventsEnabled(email string, agent *kubernetes.Agent) bool {
+	return isPorterAgentUpdated(agent, 3, 1, 6) && strings.HasSuffix(email, "porter.run")
+}

+ 25 - 1
api/types/porter_app.go

@@ -106,16 +106,40 @@ const (
 	PorterAppEventType_AppEvent PorterAppEventType = "APP_EVENT"
 )
 
+// PorterAppEventStatus is an alias for a string that represents a Porter Stack Event Status
+type PorterAppEventStatus string
+
+const (
+	// PorterAppEventStatus_Success represents a Porter Stack Event that was successful
+	PorterAppEventStatus_Success PorterAppEventStatus = "SUCCESS"
+	// PorterAppEventStatus_Failed represents a Porter Stack Event that failed
+	PorterAppEventStatus_Failed PorterAppEventStatus = "FAILED"
+	// PorterAppEventStatus_Progressing represents a Porter Stack Event that is in progress
+	PorterAppEventStatus_Progressing PorterAppEventStatus = "PROGRESSING"
+	// PorterAppEventStatus_Canceled represents a Porter Stack Event that has been canceled
+	PorterAppEventStatus_Canceled PorterAppEventStatus = "CANCELED"
+)
+
 // PorterAppEvent represents a simplified event for creating a Porter stack app event
 // swagger:model
 type CreateOrUpdatePorterAppEventRequest struct {
 	// ID, if supplied, will be assumed to be an update event
 	ID string `json:"id"`
 	// Status contains the accepted status' of a given event such as SUCCESS, FAILED, PROGRESSING, etc.
-	Status string `json:"status,omitempty"`
+	Status PorterAppEventStatus `json:"status,omitempty"`
 	// Type represents a supported Porter Stack Event
 	Type PorterAppEventType `json:"type"`
 	// TypeExternalSource represents an external event source such as Github, or Gitlab. This is not always required but will commonly be see in build events
 	TypeExternalSource string         `json:"type_source,omitempty"`
 	Metadata           map[string]any `json:"metadata,omitempty"`
 }
+
+// ServiceDeploymentMetadata contains information about a service when it deploys
+type ServiceDeploymentMetadata struct {
+	// Status is the status of the service deployment
+	Status PorterAppEventStatus `json:"status"`
+	// ExternalURI is the external URI of a service (if it is web)
+	ExternalURI string `json:"external_uri"`
+	// Type is the type of the service - one of web, worker, or job
+	Type string `json:"type"`
+}

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

@@ -415,6 +415,7 @@ const ExpandHeader = styled.div<{ isExpanded: boolean }>`
     margin-left: -7px;
     transform: ${(props) =>
     props.isExpanded ? "rotate(0deg)" : "rotate(-90deg)"};
+    transition: transform 0.1s ease;
   }
 `;
 

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

@@ -906,6 +906,7 @@ const ExpandHeader = styled.div<{ isExpanded: boolean }>`
     margin-left: -7px;
     transform: ${(props) =>
     props.isExpanded ? "rotate(0deg)" : "rotate(-90deg)"};
+    transition: transform 0.1s ease;
   }
 `;
 

+ 1 - 1
dashboard/src/components/porter/Icon.tsx

@@ -19,7 +19,7 @@ const Icon: React.FC<Props> = ({
 
 export default Icon;
 
-const StyledIcon = styled.img<{ 
+const StyledIcon = styled.img<{
   height?: string;
   opacity?: number;
 }>`

+ 38 - 18
dashboard/src/components/porter/Link.tsx

@@ -1,15 +1,15 @@
 import DynamicLink from "components/DynamicLink";
-import React, { useEffect, useState } from "react";
+import React from "react";
 import styled from "styled-components";
 
-import Icon from "components/porter/Icon";
-
 type Props = {
   to?: string;
   onClick?: () => void;
   children: React.ReactNode;
   target?: string;
   hasunderline?: boolean;
+  color?: string;
+  hoverColor?: string;
 };
 
 const Link: React.FC<Props> = ({
@@ -18,24 +18,26 @@ const Link: React.FC<Props> = ({
   children,
   target,
   hasunderline,
+  color = "#ffffff",
+  hoverColor,
 }) => {
   return (
-    <LinkWrapper>
+    <LinkWrapper hoverColor={hoverColor} color={color}>
       {to ? (
-        <StyledLink to={to} target={target}>
+        <StyledLink to={to} target={target} color={color}>
           {children}
           {target === "_blank" && (
             <div>
-            <Svg data-testid="geist-icon" fill="none" height="1em" shape-rendering="geometricPrecision" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" viewBox="0 0 24 24" width="1em" data-darkreader-inline-stroke="" data-darkreader-inline-color=""><path d="M18 13v6a2 2 0 01-2 2H5a2 2 0 01-2-2V8a2 2 0 012-2h6"></path><path d="M15 3h6v6"></path><path d="M10 14L21 3"></path></Svg>
+              <Svg data-testid="geist-icon" fill="none" height="1em" shape-rendering="geometricPrecision" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" viewBox="0 0 24 24" width="1em" data-darkreader-inline-stroke="" data-darkreader-inline-color=""><path d="M18 13v6a2 2 0 01-2 2H5a2 2 0 01-2-2V8a2 2 0 012-2h6"></path><path d="M15 3h6v6"></path><path d="M10 14L21 3"></path></Svg>
             </div>
           )}
         </StyledLink>
       ) : (
-        <Div onClick={onClick}>
+        <Div onClick={onClick} color={color}>
           {children}
         </Div>
       )}
-      {hasunderline && <Underline />}
+      {hasunderline && <Underline color={color} />}
     </LinkWrapper>
   );
 };
@@ -50,28 +52,46 @@ const Svg = styled.svg`
   stroke-width: 2;
 `;
 
-const Underline = styled.div`
+const Underline = styled.div<{ color: string }>`
   position: absolute;
   left: 0px;
+  bottom: -2px;
   height: 1px;
   width: 100%;
-  background: #ffffff;
+  background: ${(props) => props.color};
 `;
 
-const LinkWrapper = styled.span`
-  position: relative;
+const StyledLink = styled(DynamicLink) <{ hasunderline?: boolean, color: string }>`
+  color: ${(props) => props.color};
+  display: inline-flex;
+  font-size: 13px;
+  cursor: pointer;
+  align-items: center;
 `;
 
-const Div = styled.span`
-  color: #ffffff;
+const Div = styled.span<{ color: string }>`
+  color: ${(props) => props.color};
   cursor: pointer;
   font-size: 13px;
   display: inline-flex;
+  align-items: center;
 `;
 
-const StyledLink = styled(DynamicLink)<{ hasunderline?: boolean }>`
-  color: #ffffff;
+const LinkWrapper = styled.span<{ hoverColor?: string, color: string }>`
+  position: relative;
   display: inline-flex;
-  font-size: 13px;
-  cursor: pointer;
+  align-items: center;
+  :hover {
+    ${StyledLink} {
+      color: ${({ hoverColor, color }) => hoverColor ?? color};
+    }
+
+    ${Div} {
+      color: ${({ hoverColor, color }) => hoverColor ?? color};
+    }
+
+    ${Underline} {
+      background-color: ${({ hoverColor, color }) => hoverColor ?? color};
+    }
+  };
 `;

+ 1 - 0
dashboard/src/main/home/app-dashboard/build-settings/ProviderSelector.tsx

@@ -87,6 +87,7 @@ const ProviderSelectorStyles = {
         margin-right: 10px;
         z-index: 0;
         transform: ${(props) => (props.isOpen ? "rotate(180deg)" : "")};
+        transition: transform 0.1s ease;
       }
     `,
     Button: styled.div`

+ 18 - 17
dashboard/src/main/home/app-dashboard/expanded-app/ExpandedApp.tsx

@@ -1,5 +1,5 @@
 import React, { useEffect, useState, useContext } from "react";
-import { RouteComponentProps, useLocation, useParams, withRouter } from "react-router";
+import { RouteComponentProps, useHistory, useLocation, useParams, withRouter } from "react-router";
 import styled from "styled-components";
 import yaml from "js-yaml";
 
@@ -43,7 +43,7 @@ import StatusSectionFC from "./status/StatusSection";
 import ExpandedJob from "./expanded-job/ExpandedJob";
 import _ from "lodash";
 import AnimateHeight from "react-animate-height";
-import { NewPopulatedEnvGroup, PartialEnvGroup, PopulatedEnvGroup } from "../../../../components/porter-form/types";
+import { NewPopulatedEnvGroup } from "../../../../components/porter-form/types";
 import { BuildMethod, PorterApp } from "../types/porterApp";
 import EventFocusView from "./activity-feed/events/focus-views/EventFocusView";
 import HelmValuesTab from "./HelmValuesTab";
@@ -71,6 +71,7 @@ const validTabs = [
   "build-settings",
   "settings",
   "helm-values",
+  "job-history",
 ] as const;
 const DEFAULT_TAB = "activity";
 type ValidTab = typeof validTabs[number];
@@ -120,10 +121,12 @@ const ExpandedApp: React.FC<Props> = ({ ...props }) => {
   const [tempPorterApp, setTempPorterApp] = useState<PorterApp>();
   const [buildView, setBuildView] = useState<BuildMethod>("docker");
 
+  const history = useHistory();
+
   const { tab } = useParams<Params>();
   const { search } = useLocation();
   const queryParams = new URLSearchParams(search);
-  const logFilterQueryParamOpts = {
+  const queryParamOpts = {
     revision: queryParams.get('version'),
     output_stream: queryParams.get('output_stream'),
     service: queryParams.get('service'),
@@ -148,7 +151,6 @@ const ExpandedApp: React.FC<Props> = ({ ...props }) => {
 
   // this method fetches and reconstructs the porter yaml as well as the DB info (stored in PorterApp)
   const getPorterApp = async ({ revision }: { revision: number }) => {
-    setIsLoading(true);
     const { appName } = props.match.params as any;
     try {
       if (!currentCluster || !currentProject) {
@@ -449,6 +451,9 @@ const ExpandedApp: React.FC<Props> = ({ ...props }) => {
         "An error occurred while deploying your app. Please try again.";
       setButtonStatus(<Error message={errMessage} />);
     }
+
+    // redirect to the default tab
+    history.push(`/apps/${appData.app.name}/${DEFAULT_TAB}`);
   };
 
   const fetchPorterYamlContent = async (
@@ -683,9 +688,9 @@ const ExpandedApp: React.FC<Props> = ({ ...props }) => {
       case "logs":
         return <LogSection
           currentChart={appData.chart}
-          services={services.filter(Service.isNonRelease)}
+          services={services.filter(svc => Service.isNonRelease(svc) && !Service.isJob(svc))}
           appName={appData.app.name}
-          filterOpts={logFilterQueryParamOpts}
+          filterOpts={queryParamOpts}
         />;
       case "metrics":
         return <MetricsSection currentChart={appData.chart} />;
@@ -714,7 +719,13 @@ const ExpandedApp: React.FC<Props> = ({ ...props }) => {
           currentChart={appData.chart}
           updatePorterApp={updatePorterApp}
           buttonStatus={buttonStatus}
-        />
+        />;
+      case "job-history":
+        return <ExpandedJob
+          appName={appData.app.name}
+          jobName={queryParamOpts.service}
+          goBack={() => setExpandedJob(null)}
+        />;
       default:
         return <ActivityFeed
           chart={appData.chart}
@@ -724,16 +735,6 @@ const ExpandedApp: React.FC<Props> = ({ ...props }) => {
     }
   };
 
-  if (expandedJob) {
-    return (
-      <ExpandedJob
-        appName={appData.app.name}
-        jobName={expandedJob}
-        goBack={() => setExpandedJob(null)}
-      />
-    );
-  }
-
   return (
     <>
       {isLoading && <Loading />}

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

@@ -485,6 +485,7 @@ const RevisionHeader = styled.div`
     border-radius: 20px;
     transform: ${(props: { showRevisions: boolean; isCurrent: boolean }) =>
         props.showRevisions ? "" : "rotate(-90deg)"};
+    transition: transform 0.1s ease;
   }
 `;
 

+ 13 - 10
dashboard/src/main/home/app-dashboard/expanded-app/StatusFooter.tsx

@@ -19,6 +19,7 @@ import { timeFormat } from "d3-time-format";
 import AnimateHeight, { Height } from "react-animate-height";
 import { ControllerTabPodType } from "./status/ControllerTab";
 import _ from "lodash";
+import Link from "components/porter/Link";
 
 type Props = RouteComponentProps & {
   chart: any;
@@ -274,16 +275,18 @@ const StatusFooter: React.FC<Props> = ({
               Last run succeeded at 12:39 PM on 4/13/23
             </Text>
             */}
-            <Button
-              onClick={() => setExpandedJob(service.name)}
-              height="30px"
-              width="87px"
-              color="#ffffff11"
-              withBorder
-            >
-              <I className="material-icons">open_in_new</I>
-              History
-            </Button>
+            <Link to={`/apps/${chart.name}/job-history?service=${service.name}`}>
+              <Button
+                onClick={() => { }}
+                height="30px"
+                width="87px"
+                color="#ffffff11"
+                withBorder
+              >
+                <I className="material-icons">open_in_new</I>
+                History
+              </Button>
+            </Link>
           </Container>
         )}
       </StyledStatusFooter>

+ 10 - 2
dashboard/src/main/home/app-dashboard/expanded-app/activity-feed/ActivityFeed.tsx

@@ -19,7 +19,7 @@ import _ from "lodash";
 import Button from "components/porter/Button";
 import Icon from "components/porter/Icon";
 import Container from "components/porter/Container";
-import { PorterAppEvent } from "./events/types";
+import { PorterAppEvent, PorterAppEventType } from "./events/types";
 
 type Props = {
   chart: any;
@@ -70,6 +70,14 @@ const ActivityFeed: React.FC<Props> = ({ chart, stackName, appData }) => {
     }
   };
 
+  const getLatestDeployEventIndex = () => {
+    const deployEvents = events.filter((event) => event.type === PorterAppEventType.DEPLOY);
+    if (deployEvents.length === 0) {
+      return -1;
+    }
+    return events.indexOf(deployEvents[0]);
+  };
+
   const updateEvents = async () => {
     if (!currentProject || !currentCluster) {
       return;
@@ -209,7 +217,7 @@ const ActivityFeed: React.FC<Props> = ({ chart, stackName, appData }) => {
               <Spacer x={0.5} />
               <Text>{feedDate(event.created_at).split(", ")[1]}</Text>
             </Time>
-            <EventCard appData={appData} event={event} key={i} />
+            <EventCard appData={appData} event={event} key={i} isLatestDeployEvent={i === getLatestDeployEventIndex()} />
           </EventWrapper>
         );
       })}

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

@@ -44,23 +44,24 @@ const AppEventCard: React.FC<Props> = ({ event, appData }) => {
         }
       )
 
-      const updatedLogs = logResp.data.logs.map((l: { line: string; timestamp: string; }, index: number) => {
-        try {
-          return {
-            line: JSON.parse(l.line)?.log ?? Anser.ansiToJson(l.line),
-            lineNumber: index + 1,
-            timestamp: l.timestamp,
+      if (logResp.data?.logs != null) {
+        const updatedLogs = logResp.data.logs.map((l: { line: string; timestamp: string; }, index: number) => {
+          try {
+            return {
+              line: JSON.parse(l.line)?.log ?? Anser.ansiToJson(l.line),
+              lineNumber: index + 1,
+              timestamp: l.timestamp,
+            }
+          } catch (err) {
+            return {
+              line: Anser.ansiToJson(l.line),
+              lineNumber: index + 1,
+              timestamp: l.timestamp,
+            }
           }
-        } catch (err) {
-          return {
-            line: Anser.ansiToJson(l.line),
-            lineNumber: index + 1,
-            timestamp: l.timestamp,
-          }
-        }
-      });
-
-      setLogs(updatedLogs);
+        });
+        setLogs(updatedLogs);
+      }
     } catch (error) {
       console.log(error);
     }

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

@@ -15,7 +15,7 @@ import api from "shared/api";
 import { Log } from "main/home/cluster-dashboard/expanded-chart/logs-section/useAgentLogs";
 import JSZip from "jszip";
 import Anser, { AnserJsonEntry } from "anser";
-import { getDuration, getStatusIcon, triggerWorkflow } from '../utils';
+import { getDuration, getStatusColor, getStatusIcon, triggerWorkflow } from '../utils';
 import { StyledEventCard } from "./EventCard";
 import document from "assets/document.svg";
 import { PorterAppEvent } from "../types";
@@ -29,11 +29,11 @@ const BuildEventCard: React.FC<Props> = ({ event, appData }) => {
   const renderStatusText = (event: PorterAppEvent) => {
     switch (event.status) {
       case "SUCCESS":
-        return <Text color="#68BF8B">Build succeeded</Text>;
+        return <Text color={getStatusColor(event.status)}>Build succeeded</Text>;
       case "FAILED":
-        return <Text color="#FF6060">Build failed</Text>;
+        return <Text color={getStatusColor(event.status)}>Build failed</Text>;
       default:
-        return <Text color="helper">Build in progress...</Text>;
+        return <Text color={getStatusColor(event.status)}>Build in progress...</Text>;
     }
   };
 

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

@@ -1,96 +1,111 @@
 import React, { useState } from "react";
-
-
 import deploy from "assets/deploy.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 { getStatusIcon } from '../utils';
+import { getStatusColor, getStatusIcon } from '../utils';
 import { StyledEventCard } from "./EventCard";
 import styled from "styled-components";
 import Link from "components/porter/Link";
 import ChangeLogModal from "../../../ChangeLogModal";
 import { PorterAppDeployEvent } from "../types";
 import AnimateHeight from "react-animate-height";
+import ServiceStatusDetail from "./ServiceStatusDetail";
 
 type Props = {
   event: PorterAppDeployEvent;
   appData: any;
+  showServiceStatusDetail?: boolean;
 };
 
-const DeployEventCard: React.FC<Props> = ({ event, appData }) => {
+const DeployEventCard: React.FC<Props> = ({ event, appData, showServiceStatusDetail = false }) => {
   const [diffModalVisible, setDiffModalVisible] = useState(false);
   const [revertModalVisible, setRevertModalVisible] = useState(false);
-  const [serviceStatusVisible, setServiceStatusVisible] = useState(false);
+  const [serviceStatusVisible, setServiceStatusVisible] = useState(showServiceStatusDetail);
 
   const renderStatusText = () => {
     switch (event.status) {
       case "SUCCESS":
         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">
+          event.metadata.service_deployment_metadata != null ?
+            <StatusTextContainer>
+              <Text color={getStatusColor(event.status)}>
+                Deployed <Code>{event.metadata.image_tag}</Code> to
+              </Text>
+              <Spacer inline x={0.25} />
+              {renderServiceDropdownCta(Object.keys(event.metadata.service_deployment_metadata).length, getStatusColor(event.status))}
+            </StatusTextContainer>
+            :
+            <Text color={getStatusColor(event.status)}>
               Deployed <Code>{event.metadata.image_tag}</Code>
             </Text>
           :
-          <Text color="#68BF8B">
+          <Text color={getStatusColor(event.status)}>
             Deployment successful
           </Text>;
       case "FAILED":
-        if (event.metadata.service_status != null) {
+        if (event.metadata.service_deployment_metadata != null) {
           let failedServices = 0;
-          for (const key in event.metadata.service_status) {
-            if (event.metadata.service_status[key] === "FAILED") {
+          for (const key in event.metadata.service_deployment_metadata) {
+            if (event.metadata.service_deployment_metadata[key].status === "FAILED") {
               failedServices++;
             }
           }
           return (
-            <Text color="#FF6060">
-              Failed to deploy <Code>{event.metadata.image_tag}</Code> to {failedServices} service{failedServices === 1 ? "" : "s"}
-            </Text>
+            <StatusTextContainer>
+              <Text color={getStatusColor(event.status)}>
+                Failed to deploy <Code>{event.metadata.image_tag}</Code> to
+              </Text>
+              <Spacer inline x={0.25} />
+              {renderServiceDropdownCta(failedServices, getStatusColor(event.status))}
+            </StatusTextContainer>
           );
         } else {
           return (
-            <Text color="#FF6060">
+            <Text color={getStatusColor(event.status)}>
               Deployment failed
             </Text>
           );
         }
       case "CANCELED":
-        if (event.metadata.service_status != null) {
+        if (event.metadata.service_deployment_metadata != null) {
           let canceledServices = 0;
-          for (const key in event.metadata.service_status) {
-            if (event.metadata.service_status[key] === "CANCELED") {
+          for (const key in event.metadata.service_deployment_metadata) {
+            if (event.metadata.service_deployment_metadata[key].status === "CANCELED") {
               canceledServices++;
             }
           }
           return (
-            <Text color="#FFBF00">
-              Canceled deploy of <Code>{event.metadata.image_tag}</Code> to {canceledServices} service{canceledServices === 1 ? "" : "s"}
-            </Text>
+            <StatusTextContainer>
+              <Text color={getStatusColor(event.status)}>
+                Canceled deploy of <Code>{event.metadata.image_tag}</Code> to
+              </Text>
+              <Spacer inline x={0.25} />
+              {renderServiceDropdownCta(canceledServices, getStatusColor(event.status))}
+            </StatusTextContainer>
           );
         } else {
           return (
-            <Text color="#FFBF00">
+            <Text color={getStatusColor(event.status)}>
               Deployment canceled
             </Text>
           );
         }
       default:
-        if (event.metadata.service_status != null) {
+        if (event.metadata.service_deployment_metadata != 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>
+            <StatusTextContainer>
+              <Text color={getStatusColor(event.status)}>
+                Deploying <Code>{event.metadata.image_tag}</Code> to
+              </Text>
+              <Spacer inline x={0.25} />
+              {renderServiceDropdownCta(Object.keys(event.metadata.service_deployment_metadata).length, getStatusColor(event.status))}
+            </StatusTextContainer>
           );
         } else {
           return (
-            <Text color="helper">
+            <Text color={getStatusColor(event.status)}>
               Deploying <Code>{event.metadata.image_tag}</Code>...
             </Text>
           );
@@ -98,47 +113,17 @@ const DeployEventCard: React.FC<Props> = ({ event, appData }) => {
     }
   };
 
-  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>
+  const renderServiceDropdownCta = (numServices: number, color?: string) => {
+    return (
+      <ServiceStatusDropdownCtaContainer >
+        <Link color={color} onClick={() => setServiceStatusVisible(!serviceStatusVisible)}>
+          <ServiceStatusDropdownIcon className="material-icons" serviceStatusVisible={serviceStatusVisible}>arrow_drop_down</ServiceStatusDropdownIcon>
+          {numServices} service{numServices === 1 ? "" : "s"}
+        </Link>
+      </ServiceStatusDropdownCtaContainer>
+    )
   }
+
   return (
     <StyledEventCard>
       <Container row spaced>
@@ -154,16 +139,6 @@ const DeployEventCard: React.FC<Props> = ({ event, appData }) => {
           <Icon height="12px" src={getStatusIcon(event.status)} />
           <Spacer inline width="10px" />
           {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} />
@@ -202,10 +177,16 @@ 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>
+      {event.metadata.service_deployment_metadata != null &&
+        <AnimateHeight height={serviceStatusVisible ? "auto" : 0}>
+          <Spacer y={0.5} />
+          <ServiceStatusDetail
+            serviceDeploymentMetadata={event.metadata.service_deployment_metadata}
+            appName={appData.app.name}
+            revision={event.metadata.revision}
+          />
+        </AnimateHeight>
+      }
     </StyledEventCard>
   );
 };
@@ -221,17 +202,28 @@ const Code = styled.span`
   font-family: monospace;
 `;
 
-const ServiceStatusContainer = styled.div`
+const ServiceStatusDropdownCtaContainer = styled.div`
   display: flex;
-  align-items: center;  
-  width: 150px;
-  overflow: hidden;
-  text-overflow: ellipsis;
-  white-space: nowrap;
+  justify-content: center;
+  cursor: pointer;
+  padding: 3px 5px;
+  border-radius: 5px;
+  :hover {
+    background: #ffffff11;
+  }
 `;
 
-const ServiceStatusesContainer = styled.div`
+const ServiceStatusDropdownIcon = styled.i`
+  margin-left: -5px;
+  font-size: 20px;
+  border-radius: 20px;
+  transform: ${(props: { serviceStatusVisible: boolean }) =>
+    props.serviceStatusVisible ? "" : "rotate(-90deg)"};
+  transition: transform 0.1s ease;
+`
+
+const StatusTextContainer = styled.div`
   display: flex;
-  flex-direction: column;
-  gap: 10px;
- `; 
+  align-items: center;
+  flex-direction: row;
+`;

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

@@ -10,9 +10,10 @@ import { PorterAppDeployEvent, PorterAppEvent, PorterAppEventType } from "../typ
 type Props = {
   event: PorterAppEvent;
   appData: any;
+  isLatestDeployEvent?: boolean;
 };
 
-const EventCard: React.FC<Props> = ({ event, appData }) => {
+const EventCard: React.FC<Props> = ({ event, appData, isLatestDeployEvent }) => {
   const renderEventCard = (event: PorterAppEvent) => {
     switch (event.type) {
       case PorterAppEventType.APP_EVENT:
@@ -20,7 +21,7 @@ const EventCard: React.FC<Props> = ({ event, appData }) => {
       case PorterAppEventType.BUILD:
         return <BuildEventCard event={event} appData={appData} />;
       case PorterAppEventType.DEPLOY:
-        return <DeployEventCard event={event as PorterAppDeployEvent} appData={appData} />;
+        return <DeployEventCard event={event as PorterAppDeployEvent} appData={appData} showServiceStatusDetail={isLatestDeployEvent} />;
       case PorterAppEventType.PRE_DEPLOY:
         return <PreDeployEventCard event={event} appData={appData} />;
       default:

+ 126 - 0
dashboard/src/main/home/app-dashboard/expanded-app/activity-feed/events/cards/ServiceStatusDetail.tsx

@@ -0,0 +1,126 @@
+import Icon from 'components/porter/Icon';
+import Spacer from 'components/porter/Spacer';
+import Text from 'components/porter/Text';
+import React from 'react'
+import styled from 'styled-components';
+import { getStatusColor, getStatusIcon } from '../utils';
+import Link from 'components/porter/Link';
+import { PorterAppDeployEvent } from "../types";
+import { Service } from 'main/home/app-dashboard/new-app-flow/serviceTypes';
+
+type Props = {
+    serviceDeploymentMetadata: PorterAppDeployEvent["metadata"]["service_deployment_metadata"];
+    appName: string;
+    revision: number;
+}
+
+const ServiceStatusDetail: React.FC<Props> = ({
+    serviceDeploymentMetadata,
+    appName,
+    revision,
+}) => {
+    const convertEventStatusToCopy = (status: string) => {
+        switch (status) {
+            case "PROGRESSING":
+                return "DEPLOYING";
+            case "SUCCESS":
+                return "DEPLOYED";
+            case "FAILED":
+                return "FAILED";
+            case "CANCELED":
+                return "CANCELED";
+            default:
+                return "UNKNOWN";
+        }
+    };
+
+    return (
+        <ServiceStatusTable>
+            <tbody>
+                {Object.keys(serviceDeploymentMetadata).map((key) => {
+                    const deploymentMetadata = serviceDeploymentMetadata[key];
+                    return (
+                        <ServiceStatusTableRow key={key}>
+                            <ServiceStatusTableData width={"100px"}>
+                                <Text>{key}</Text>
+                            </ServiceStatusTableData>
+                            <ServiceStatusTableData width={"120px"}>
+                                <Icon height="12px" src={getStatusIcon(deploymentMetadata.status)} />
+                                <Spacer inline x={0.5} />
+                                <Text color={getStatusColor(deploymentMetadata.status)}>{convertEventStatusToCopy(serviceDeploymentMetadata[key].status)}</Text>
+                            </ServiceStatusTableData>
+                            <ServiceStatusTableData>
+                                {deploymentMetadata.type !== "job" &&
+                                    <>
+                                        <Link
+                                            to={`/apps/${appName}/logs?version=${revision}&service=${key}`}
+                                            hasunderline
+                                            hoverColor="#949eff"
+                                        >
+                                            Logs
+                                        </Link>
+                                        <Spacer inline x={0.5} />
+                                        <Link
+                                            to={`/apps/${appName}/logs?version=${revision}&service=${key}`}
+                                            hasunderline
+                                            hoverColor="#949eff"
+                                        >
+                                            Metrics
+                                        </Link>
+                                    </>
+                                }
+                                {deploymentMetadata.type === "job" &&
+                                    <>
+                                        <Link
+                                            to={`/apps/${appName}/job-history?service=${key}`}
+                                            hasunderline
+                                            hoverColor="#949eff"
+                                        >
+                                            History
+                                        </Link>
+                                    </>
+                                }
+                                {deploymentMetadata.external_uri !== "" &&
+                                    <>
+                                        <Spacer inline x={0.5} />
+                                        <Link
+                                            to={Service.prefixSubdomain(deploymentMetadata.external_uri)}
+                                            hasunderline
+                                            hoverColor="#949eff"
+                                            target={"_blank"}
+                                        >
+                                            External Link
+                                        </Link>
+                                    </>
+                                }
+                            </ServiceStatusTableData>
+                        </ServiceStatusTableRow>
+                    );
+                })}
+            </tbody>
+        </ServiceStatusTable>
+    )
+}
+
+export default ServiceStatusDetail;
+
+const ServiceStatusTable = styled.table`
+  border-collapse: collapse;
+  width: 100%;
+`;
+
+const ServiceStatusTableRow = styled.tr`
+  display: flex;
+  align-items: center;  
+`;
+
+const ServiceStatusTableData = styled.td`
+  padding: 8px;
+  display: flex;
+  align-items: center;
+  ${(props) => props.width && `width: ${props.width};`}
+
+  &:not(:last-child) {
+    border-right: 2px solid #ffffff11;
+  }
+`;

+ 7 - 1
dashboard/src/main/home/app-dashboard/expanded-app/activity-feed/events/types.ts

@@ -28,11 +28,17 @@ export const PorterAppEvent = {
         };
     }
 }
+
+interface PorterAppServiceDeploymentMetadata {
+    status: string;
+    external_uri: string;
+    type: string;
+}
 export interface PorterAppDeployEvent extends PorterAppEvent {
     type: PorterAppEventType.DEPLOY;
     metadata: {
         image_tag: string;
         revision: number;
-        service_status: Record<string, string>;
+        service_deployment_metadata: Record<string, PorterAppServiceDeploymentMetadata>;
     };
 }

+ 15 - 0
dashboard/src/main/home/app-dashboard/expanded-app/activity-feed/events/utils.ts

@@ -52,6 +52,21 @@ export const getStatusIcon = (status: string) => {
     }
 };
 
+export const getStatusColor = (status: string) => {
+    switch (status) {
+        case "SUCCESS":
+            return "#68BF8B";
+        case "FAILED":
+            return "#FF6060";
+        case "PROGRESSING":
+            return "#6e9df5";
+        case "CANCELED":
+            return "#FFBF00";
+        default:
+            return "#6e9df5";
+    }
+};
+
 export const triggerWorkflow = async (appData: any) => {
     try {
         const res = await api.reRunGHWorkflow(

+ 2 - 3
dashboard/src/main/home/app-dashboard/expanded-app/expanded-job/ExpandedJob.tsx

@@ -30,11 +30,11 @@ type Props = RouteComponentProps & {
   goBack: () => void;
 };
 
-const ExpandedJob: React.FC<Props> = ({ 
+const ExpandedJob: React.FC<Props> = ({
   appName,
   jobName,
   goBack,
-  ...props 
+  ...props
 }) => {
   const { currentCluster, currentProject, setCurrentError } = useContext(
     Context
@@ -55,7 +55,6 @@ const ExpandedJob: React.FC<Props> = ({
       )}
       {!isLoading && !expandedRun && (
         <StyledExpandedApp>
-          <Back onClick={goBack} />
           <Container row>
             <Icon src={history} />
             <Text size={21}>Run history for "{jobName}"</Text>

+ 3 - 0
dashboard/src/main/home/app-dashboard/expanded-app/logs/LogSection.tsx

@@ -91,6 +91,9 @@ const LogSection: React.FC<Props> = ({
       return false;
     }
     const version = agentImage.split(":").pop();
+    if (version === "dev") {
+      return true;
+    }
     //make sure version is above v3.1.3
     if (version == null) {
       return false;

+ 25 - 0
internal/repository/gorm/porter_app_event.go

@@ -2,6 +2,7 @@ package gorm
 
 import (
 	"context"
+	"encoding/json"
 	"errors"
 	"strconv"
 	"time"
@@ -101,3 +102,27 @@ func (repo *PorterAppEventRepository) ReadEvent(ctx context.Context, id uuid.UUI
 
 	return appEvent, nil
 }
+
+func (repo *PorterAppEventRepository) ReadDeployEventByRevision(ctx context.Context, porterAppID uint, revision float64) (models.PorterAppEvent, error) {
+	appEvent := models.PorterAppEvent{}
+
+	if porterAppID == 0 {
+		return appEvent, errors.New("invalid porter app ID supplied")
+	}
+
+	// Convert porterAppID to string
+	strAppID := strconv.Itoa(int(porterAppID))
+
+	// Convert revision to JSON number string
+	revJSON, err := json.Marshal(revision)
+	if err != nil {
+		return appEvent, errors.New("unable to marshal revision")
+	}
+	strRevision := string(revJSON)
+
+	if err := repo.db.Where("porter_app_id = ? AND type = 'DEPLOY' AND metadata->>'revision' = ?", strAppID, strRevision).First(&appEvent).Error; err != nil {
+		return appEvent, err
+	}
+
+	return appEvent, nil
+}

+ 1 - 0
internal/repository/porter_app_event.go

@@ -14,4 +14,5 @@ type PorterAppEventRepository interface {
 	CreateEvent(ctx context.Context, appEvent *models.PorterAppEvent) error
 	UpdateEvent(ctx context.Context, appEvent *models.PorterAppEvent) error
 	ReadEvent(ctx context.Context, id uuid.UUID) (models.PorterAppEvent, error)
+	ReadDeployEventByRevision(ctx context.Context, porterAppID uint, revision float64) (models.PorterAppEvent, error)
 }

+ 4 - 0
internal/repository/test/porter_app_event.go

@@ -33,3 +33,7 @@ func (repo *PorterAppEventRepository) UpdateEvent(ctx context.Context, appEvent
 func (repo *PorterAppEventRepository) ReadEvent(ctx context.Context, id uuid.UUID) (models.PorterAppEvent, error) {
 	return models.PorterAppEvent{}, errors.New("cannot read database")
 }
+
+func (repo *PorterAppEventRepository) ReadDeployEventByRevision(ctx context.Context, porterAppID uint, revision float64) (models.PorterAppEvent, error) {
+	return models.PorterAppEvent{}, errors.New("cannot read database")
+}