Explorar o código

[POR-1616] [POR-1469] [POR-1881] [POR-2026] Backend changes to support notifications (#3921)

Feroze Mohideen %!s(int64=2) %!d(string=hai) anos
pai
achega
10cbb20583
Modificáronse 47 ficheiros con 3760 adicións e 1083 borrados
  1. 64 0
      api/server/handlers/porter_app/create_and_update_events.go
  2. 33 3
      api/server/handlers/porter_app/current_app_revision.go
  3. 1 1
      api/server/handlers/porter_app/list_events_apply_v2.go
  4. 2 0
      api/types/porter_app.go
  5. 3 0
      dashboard/src/assets/alert-red.svg
  6. 3 0
      dashboard/src/assets/alert-triangle.svg
  7. 3 0
      dashboard/src/assets/alert-warning.svg
  8. 3 0
      dashboard/src/assets/arrow-left-square-contained.svg
  9. 3 0
      dashboard/src/assets/bar-group-03.svg
  10. 3 0
      dashboard/src/assets/calendar-02.svg
  11. 3 0
      dashboard/src/assets/edit-contained.svg
  12. 3 0
      dashboard/src/assets/external-link.svg
  13. 4 0
      dashboard/src/assets/fast-backward.svg
  14. 39 9
      dashboard/src/components/TabSelector.tsx
  15. 12 9
      dashboard/src/components/porter/Container.tsx
  16. 51 0
      dashboard/src/components/porter/Tag.tsx
  17. 67 0
      dashboard/src/lib/porter-apps/notification.ts
  18. 42 2
      dashboard/src/main/home/app-dashboard/app-view/AppDataContainer.tsx
  19. 40 9
      dashboard/src/main/home/app-dashboard/app-view/LatestRevisionContext.tsx
  20. 9 14
      dashboard/src/main/home/app-dashboard/app-view/tabs/Activity.tsx
  21. 21 11
      dashboard/src/main/home/app-dashboard/app-view/tabs/MetricsTab.tsx
  22. 28 0
      dashboard/src/main/home/app-dashboard/app-view/tabs/Notifications.tsx
  23. 216 212
      dashboard/src/main/home/app-dashboard/app-view/tabs/activity-feed/ActivityFeed.tsx
  24. 81 64
      dashboard/src/main/home/app-dashboard/app-view/tabs/activity-feed/events/cards/BuildEventCard.tsx
  25. 25 19
      dashboard/src/main/home/app-dashboard/app-view/tabs/activity-feed/events/cards/DeployEventCard.tsx
  26. 22 18
      dashboard/src/main/home/app-dashboard/app-view/tabs/activity-feed/events/cards/EventCard.tsx
  27. 90 50
      dashboard/src/main/home/app-dashboard/app-view/tabs/activity-feed/events/cards/PreDeployEventCard.tsx
  28. 168 131
      dashboard/src/main/home/app-dashboard/app-view/tabs/activity-feed/events/cards/ServiceStatusDetail.tsx
  29. 117 63
      dashboard/src/main/home/app-dashboard/app-view/tabs/activity-feed/events/types.ts
  30. 301 0
      dashboard/src/main/home/app-dashboard/app-view/tabs/notifications/NotificationExpandedView.tsx
  31. 141 0
      dashboard/src/main/home/app-dashboard/app-view/tabs/notifications/NotificationFeed.tsx
  32. 64 0
      dashboard/src/main/home/app-dashboard/app-view/tabs/notifications/NotificationList.tsx
  33. 133 0
      dashboard/src/main/home/app-dashboard/app-view/tabs/notifications/NotificationTile.tsx
  34. 0 1
      dashboard/src/main/home/app-dashboard/validate-apply/build-settings/docker/DockerfileSettings.tsx
  35. 561 461
      dashboard/src/main/home/app-dashboard/validate-apply/logs/Logs.tsx
  36. 2 6
      dashboard/src/main/home/app-dashboard/validate-apply/metrics/MetricsSection.tsx
  37. 1 0
      dashboard/src/shared/themes/midnight.ts
  38. 15 0
      internal/kubernetes/agent.go
  39. 356 0
      internal/porter_app/notifications/app_event.go
  40. 220 0
      internal/porter_app/notifications/deployment.go
  41. 155 0
      internal/porter_app/notifications/notification.go
  42. 123 0
      internal/porter_app/notifications/porter_error/codes.go
  43. 372 0
      internal/porter_app/notifications/porter_error/providers.go
  44. 93 0
      internal/porter_app/notifications/translate.go
  45. 55 0
      internal/repository/gorm/porter_app_event.go
  46. 2 0
      internal/repository/porter_app_event.go
  47. 10 0
      internal/repository/test/porter_app_event.go

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

@@ -16,7 +16,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/deployment_target"
+	"github.com/porter-dev/porter/internal/kubernetes"
 	"github.com/porter-dev/porter/internal/models"
+	"github.com/porter-dev/porter/internal/porter_app/notifications"
 	"github.com/porter-dev/porter/internal/telemetry"
 )
 
@@ -32,6 +35,7 @@ func NewCreateUpdatePorterAppEventHandler(
 ) *CreateUpdatePorterAppEventHandler {
 	return &CreateUpdatePorterAppEventHandler{
 		PorterHandlerReadWriter: handlers.NewDefaultPorterHandler(config, decoderValidator, writer),
+		KubernetesAgentGetter:   authz.NewOutOfClusterAgentGetter(config),
 	}
 }
 
@@ -74,6 +78,30 @@ func (p *CreateUpdatePorterAppEventHandler) ServeHTTP(w http.ResponseWriter, r *
 		reportBuildStatus(ctx, request, p.Config(), user, project, appName, validateApplyV2)
 	}
 
+	// This branch will only be hit for v2 app_event type events
+	if request.ID == "" && request.DeploymentTargetID != "" && request.Type == types.PorterAppEventType_AppEvent {
+		agent, err := p.GetAgent(r, cluster, "")
+		if err != nil {
+			err := telemetry.Error(ctx, span, err, "error getting agent")
+			p.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
+			return
+		}
+
+		if agent == nil {
+			err := telemetry.Error(ctx, span, nil, "agent not found")
+			p.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
+			return
+		}
+
+		err = p.handleNotification(ctx, request, project.ID, cluster.ID, *agent)
+		if err != nil {
+			e := telemetry.Error(ctx, span, err, "error handling notification")
+			p.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(e, http.StatusInternalServerError))
+			return
+		}
+		return
+	}
+
 	if request.ID == "" {
 		event, err := p.createNewAppEvent(ctx, *cluster, appName, request.DeploymentTargetID, request.Status, string(request.Type), request.TypeExternalSource, request.Metadata)
 		if err != nil {
@@ -591,3 +619,39 @@ func (p *CreateUpdatePorterAppEventHandler) updateDeployEventMatchingAppEventDet
 	_ = p.updateDeployEvent(ctx, porterAppName, porterAppId, deploymentTargetID, updateMetadataMap)
 	return nil
 }
+
+// handleNotification handles all logic for notifications in app v2
+func (p *CreateUpdatePorterAppEventHandler) handleNotification(ctx context.Context,
+	request *types.CreateOrUpdatePorterAppEventRequest,
+	projectId, clusterId uint,
+	agent kubernetes.Agent,
+) error {
+	ctx, span := telemetry.NewSpan(ctx, "serve-handle-notification")
+	defer span.End()
+
+	// get the namespace associated with the deployment target id
+	deploymentTarget, err := deployment_target.DeploymentTargetDetails(ctx, deployment_target.DeploymentTargetDetailsInput{
+		ProjectID:          int64(projectId),
+		ClusterID:          int64(clusterId),
+		DeploymentTargetID: request.DeploymentTargetID,
+		CCPClient:          p.Config().ClusterControlPlaneClient,
+	})
+	if err != nil {
+		return telemetry.Error(ctx, span, err, "error getting deployment target details")
+	}
+
+	inp := notifications.HandleNotificationInput{
+		RawAgentEventMetadata: request.Metadata,
+		EventRepo:             p.Repo().PorterAppEvent(),
+		DeploymentTargetID:    request.DeploymentTargetID,
+		Namespace:             deploymentTarget.Namespace,
+		K8sAgent:              agent,
+	}
+
+	err = notifications.HandleNotification(ctx, inp)
+	if err != nil {
+		return telemetry.Error(ctx, span, err, "error handling notification")
+	}
+
+	return nil
+}

+ 33 - 3
api/server/handlers/porter_app/current_app_revision.go

@@ -13,6 +13,7 @@ import (
 	"github.com/google/uuid"
 
 	"github.com/porter-dev/porter/internal/porter_app"
+	"github.com/porter-dev/porter/internal/porter_app/notifications"
 	"github.com/porter-dev/porter/internal/telemetry"
 
 	"github.com/porter-dev/porter/api/server/handlers"
@@ -50,6 +51,8 @@ type LatestAppRevisionRequest struct {
 type LatestAppRevisionResponse struct {
 	// AppRevision is the latest revision for the app
 	AppRevision porter_app.Revision `json:"app_revision"`
+	// Notifications are the notifications associated with the app revision
+	Notifications []notifications.Notification `json:"notifications"`
 }
 
 // ServeHTTP translates the request into a CurrentAppRevision grpc request, forwards to the cluster control plane, and returns the response.
@@ -107,7 +110,10 @@ func (c *LatestAppRevisionHandler) ServeHTTP(w http.ResponseWriter, r *http.Requ
 		return
 	}
 
-	if porterApps[0].ID == 0 {
+	appId := porterApps[0].ID
+	telemetry.WithAttributes(span, telemetry.AttributeKV{Key: "app-id", Value: appId})
+
+	if appId == 0 {
 		err := telemetry.Error(ctx, span, err, "porter app id is missing")
 		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
 		return
@@ -115,7 +121,7 @@ func (c *LatestAppRevisionHandler) ServeHTTP(w http.ResponseWriter, r *http.Requ
 
 	currentAppRevisionReq := connect.NewRequest(&porterv1.CurrentAppRevisionRequest{
 		ProjectId:          int64(project.ID),
-		AppId:              int64(porterApps[0].ID),
+		AppId:              int64(appId),
 		DeploymentTargetId: request.DeploymentTargetID,
 	})
 
@@ -140,8 +146,32 @@ func (c *LatestAppRevisionHandler) ServeHTTP(w http.ResponseWriter, r *http.Requ
 		return
 	}
 
+	appRevisionId := encodedRevision.ID
+	notificationEvents, err := c.Repo().PorterAppEvent().ReadNotificationsByAppRevisionID(ctx, appId, appRevisionId)
+	if err != nil {
+		err := telemetry.Error(ctx, span, err, "error getting notifications from repo")
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
+		return
+	}
+	latestNotifications := make([]notifications.Notification, 0)
+	for _, event := range notificationEvents {
+		notification, err := notifications.NotificationFromPorterAppEvent(event)
+		if err != nil {
+			err := telemetry.Error(ctx, span, err, "error converting porter app event to notification")
+			c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
+			return
+		}
+		if notification == nil {
+			err := telemetry.Error(ctx, span, err, "notification is nil")
+			c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
+			return
+		}
+		latestNotifications = append(latestNotifications, *notification)
+	}
+
 	response := LatestAppRevisionResponse{
-		AppRevision: encodedRevision,
+		AppRevision:   encodedRevision,
+		Notifications: latestNotifications,
 	}
 
 	c.WriteResult(w, r, response)

+ 1 - 1
api/server/handlers/porter_app/list_events_apply_v2.go

@@ -79,7 +79,7 @@ func (p *PorterAppV2EventListHandler) ServeHTTP(w http.ResponseWriter, r *http.R
 		return
 	}
 
-	porterAppEvents, paginatedResult, err := p.Repo().PorterAppEvent().ListEventsByPorterAppIDAndDeploymentTargetID(ctx, app.ID, uid, helpers.WithPageSize(20), helpers.WithPage(int(request.Page)))
+	porterAppEvents, paginatedResult, err := p.Repo().PorterAppEvent().ListBuildDeployEventsByPorterAppIDAndDeploymentTargetID(ctx, app.ID, uid, helpers.WithPageSize(20), helpers.WithPage(int(request.Page)))
 	if err != nil {
 		if !errors.Is(err, gorm.ErrRecordNotFound) {
 			e := telemetry.Error(ctx, span, nil, "error listing porter app events by porter app id")

+ 2 - 0
api/types/porter_app.go

@@ -118,6 +118,8 @@ const (
 	PorterAppEventType_PreDeploy PorterAppEventType = "PRE_DEPLOY"
 	// PorterAppEventType_AppEvent represents a Porter Stack App Event which occurred whilst the application was running, such as an OutOfMemory (OOM) error
 	PorterAppEventType_AppEvent PorterAppEventType = "APP_EVENT"
+	// PorterAppEventType_Notification represents a translation of the porter agent app event into the new notification format, which details everything that occurs while the app is running
+	PorterAppEventType_Notification PorterAppEventType = "NOTIFICATION"
 )
 
 // PorterAppEventStatus is an alias for a string that represents a Porter Stack Event Status

+ 3 - 0
dashboard/src/assets/alert-red.svg

@@ -0,0 +1,3 @@
+<svg width="22" height="20" viewBox="0 0 22 20" fill="none" xmlns="http://www.w3.org/2000/svg">
+<path d="M11 10.9708V6.13251M11 14.5571V14.5996M17.0479 18.6292H4.9521C3.29987 18.6292 1.90554 17.525 1.46685 16.0143C1.27959 15.3694 1.50969 14.6977 1.86163 14.1257L7.90956 3.09779C9.32653 0.7952 12.6735 0.795203 14.0905 3.0978L20.1384 14.1257C20.4904 14.6977 20.7205 15.3694 20.5332 16.0143C20.0945 17.525 18.7002 18.6292 17.0479 18.6292Z" stroke="#FF6060" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
+</svg>

+ 3 - 0
dashboard/src/assets/alert-triangle.svg

@@ -0,0 +1,3 @@
+<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
+<path d="M12 12.9708V8.13251M12 16.5571V16.5996M18.0479 20.6292H5.9521C4.29987 20.6292 2.90554 19.525 2.46685 18.0143C2.27959 17.3694 2.50969 16.6977 2.86163 16.1257L8.90956 5.09779C10.3265 2.7952 13.6735 2.7952 15.0905 5.0978L21.1384 16.1257C21.4904 16.6977 21.7205 17.3694 21.5332 18.0143C21.0945 19.525 19.7002 20.6292 18.0479 20.6292Z" stroke="white" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
+</svg>

+ 3 - 0
dashboard/src/assets/alert-warning.svg

@@ -0,0 +1,3 @@
+<svg width="22" height="20" viewBox="0 0 22 20" fill="none" xmlns="http://www.w3.org/2000/svg">
+<path d="M11 10.9708V6.13251M11 14.5571V14.5996M17.0479 18.6292H4.9521C3.29987 18.6292 1.90554 17.525 1.46685 16.0143C1.27959 15.3694 1.50969 14.6977 1.86163 14.1257L7.90956 3.09779C9.32653 0.7952 12.6735 0.795203 14.0905 3.0978L20.1384 14.1257C20.4904 14.6977 20.7205 15.3694 20.5332 16.0143C20.0945 17.525 18.7002 18.6292 17.0479 18.6292Z" stroke="#FFBF00" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
+</svg>

+ 3 - 0
dashboard/src/assets/arrow-left-square-contained.svg

@@ -0,0 +1,3 @@
+<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
+<path d="M11.3345 16.2426L7.20002 12M7.20002 12L11.3345 7.75729M7.20002 12H16.2843M21.6 5.9999L21.6 18C21.6 19.9882 19.9882 21.6 18 21.6H6.00002C4.0118 21.6 2.40002 19.9882 2.40002 18V5.9999C2.40002 4.01168 4.0118 2.3999 6.00002 2.3999H18C19.9882 2.3999 21.6 4.01168 21.6 5.9999Z" stroke="white" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
+</svg>

+ 3 - 0
dashboard/src/assets/bar-group-03.svg

@@ -0,0 +1,3 @@
+<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
+<path d="M14.9435 21.0881V3.91211C14.9435 3.35982 14.4958 2.91211 13.9435 2.91211H9.88528C9.333 2.91211 8.88528 3.35982 8.88528 3.91211V21.0881M14.9435 21.0881L14.9419 10.7683C14.9418 10.2159 15.3896 9.76811 15.9419 9.76811H20C20.5523 9.76811 21 10.2158 21 10.7681V20.0881C21 20.6404 20.5523 21.0881 20 21.0881H14.9435ZM14.9435 21.0881H8.88528M8.88528 21.0881V16.0881C8.88528 15.5358 8.43757 15.0881 7.88528 15.0881H4C3.44771 15.0881 3 15.5358 3 16.0881V20.0881C3 20.6404 3.44771 21.0881 4 21.0881H8.88528Z" stroke="white" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
+</svg>

+ 3 - 0
dashboard/src/assets/calendar-02.svg

@@ -0,0 +1,3 @@
+<svg width="25" height="24" viewBox="0 0 25 24" fill="none" xmlns="http://www.w3.org/2000/svg">
+<path d="M7.5 17.5534V17.4688M12.5625 17.5534V17.4688M12.5625 12.9688V12.8842M17.0625 12.9688V12.8842M4.125 8.46875H19.875M6.16071 2V3.68771M17.625 2V3.6875M17.625 3.6875H6.375C4.51104 3.6875 3 5.19854 3 7.0625V18.3126C3 20.1766 4.51104 21.6876 6.375 21.6876H17.625C19.489 21.6876 21 20.1766 21 18.3126L21 7.0625C21 5.19854 19.489 3.6875 17.625 3.6875Z" stroke="white" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
+</svg>

+ 3 - 0
dashboard/src/assets/edit-contained.svg

@@ -0,0 +1,3 @@
+<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
+<path d="M11.0487 3.35143H5.04873C3.06051 3.35143 1.44873 4.96321 1.44873 6.95143V18.9515C1.44873 20.9398 3.06051 22.5515 5.04873 22.5515H17.0487C19.037 22.5515 20.6487 20.9398 20.6487 18.9515L20.6487 12.9515M7.44873 16.5514L11.8147 15.6717C12.0465 15.625 12.2593 15.5109 12.4264 15.3437L22.2001 5.56461C22.6687 5.09576 22.6684 4.33577 22.1994 3.86731L20.129 1.79923C19.6602 1.33097 18.9006 1.33129 18.4322 1.79995L8.65749 11.58C8.49068 11.7469 8.37678 11.9593 8.33003 12.1906L7.44873 16.5514Z" stroke="white" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
+</svg>

+ 3 - 0
dashboard/src/assets/external-link.svg

@@ -0,0 +1,3 @@
+<svg width="22" height="22" viewBox="0 0 22 22" fill="none" xmlns="http://www.w3.org/2000/svg">
+<path d="M9.7999 1.3999H4.9999C3.01168 1.3999 1.3999 3.01168 1.3999 4.9999V17C1.3999 18.9882 3.01168 20.6 4.9999 20.6H16.9999C18.9881 20.6 20.5999 18.9882 20.5999 17V12.1999M14.5993 1.40019L20.5999 1.3999M20.5999 1.3999V6.80005M20.5999 1.3999L10.399 11.5996" stroke="white" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
+</svg>

+ 4 - 0
dashboard/src/assets/fast-backward.svg

@@ -0,0 +1,4 @@
+<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
+<path d="M13.9189 12.481L20.4389 19.001C20.8674 19.4295 21.6 19.126 21.6 18.5201L21.6 5.48012C21.6 4.87415 20.8674 4.57068 20.4389 4.99917L13.9189 11.5191C13.6533 11.7848 13.6533 12.2154 13.9189 12.481Z" stroke="white" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M2.59924 12.481L9.11921 19.001C9.5477 19.4295 10.2803 19.126 10.2803 18.5201L10.2803 5.48012C10.2803 4.87415 9.5477 4.57068 9.11921 4.99917L2.59924 11.5191C2.33362 11.7848 2.33362 12.2154 2.59924 12.481Z" stroke="white" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
+</svg>

+ 39 - 9
dashboard/src/components/TabSelector.tsx

@@ -1,15 +1,18 @@
 import React, { Component } from "react";
 import styled from "styled-components";
 
-export interface selectOption<T> {
+import Spacer from "./porter/Spacer";
+
+export type selectOption<T> = {
   value: T;
   label: string;
   component?: any;
-}
+  sibling?: JSX.Element;
+};
 
 type PropsType<T> = {
   currentTab: string;
-  options: selectOption<T>[];
+  options: Array<selectOption<T>>;
   setCurrentTab: (value: T) => void;
   addendum?: any;
   color?: string;
@@ -34,12 +37,30 @@ export default class TabSelector<T> extends Component<PropsType<T>, StateType> {
   };
 
   renderTabList = () => {
-    let color = this.props.color || "#aaaabb";
+    const color = this.props.color ?? "#aaaabb";
     return this.props.options.map((option: selectOption<T>, i: number) => {
-      return (
+      return option.sibling ? (
+        <TabWithSibling>
+          <Tab
+            key={i}
+            onClick={() => {
+              this.handleTabClick(option.value);
+            }}
+            lastItem={i === this.props.options.length - 1}
+            highlight={option.value === this.props.currentTab ? color : null}
+            style={{ marginRight: "0px" }}
+          >
+            {option.label}
+          </Tab>
+          <Spacer inline x={0.5} />
+          {option.sibling}
+        </TabWithSibling>
+      ) : (
         <Tab
           key={i}
-          onClick={() => this.handleTabClick(option.value)}
+          onClick={() => {
+            this.handleTabClick(option.value);
+          }}
           lastItem={i === this.props.options.length - 1}
           highlight={option.value === this.props.currentTab ? color : null}
         >
@@ -49,9 +70,11 @@ export default class TabSelector<T> extends Component<PropsType<T>, StateType> {
     });
   };
 
-  renderAddendumBuffer = () => {};
+  renderAddendumBuffer = (): JSX.Element => {
+    return <Buffer />;
+  };
 
-  render() {
+  render(): JSX.Element {
     return (
       <>
         <StyledTabSelector>
@@ -95,7 +118,7 @@ const TabWrapper = styled.div`
 
 const Tab = styled.div`
   height: 30px;
-  margin-right: ${(props: { lastItem: boolean; highlight: string | null}) =>
+  margin-right: ${(props: { lastItem: boolean; highlight: string | null }) =>
     props.lastItem ? "" : "30px"};
   display: flex;
   font-family: "Work Sans", sans-serif;
@@ -127,3 +150,10 @@ const StyledTabSelector = styled.div`
   margin-left: 1px;
   position: relative;
 `;
+
+const TabWithSibling = styled.div`
+  display: flex;
+  flex-direction: row;
+  align-items: center;
+  margin-right: 30px;
+`;

+ 12 - 9
dashboard/src/components/porter/Container.tsx

@@ -1,4 +1,4 @@
-import React, { useEffect, useState } from "react";
+import React from "react";
 import styled from "styled-components";
 
 type Props = {
@@ -7,6 +7,7 @@ type Props = {
   column?: boolean;
   spaced?: boolean;
   alignItems?: string;
+  style?: React.CSSProperties;
 };
 
 const Container: React.FC<Props> = ({
@@ -15,15 +16,15 @@ const Container: React.FC<Props> = ({
   spaced,
   column,
   alignItems,
+  style,
 }) => {
-  const [isExpanded, setIsExpanded] = useState(false);
-
   return (
     <StyledContainer
       spaced={spaced}
       row={row}
       column={column}
       alignItems={alignItems}
+      style={style}
     >
       {children}
     </StyledContainer>
@@ -36,10 +37,12 @@ const StyledContainer = styled.div<{
   row?: boolean;
   column?: boolean;
   spaced?: boolean;
-  alignItems?: string
+  alignItems?: string;
 }>`
-  display: ${props => props.row || props.column ? "flex" : "block"};
-  flex-direction: ${props => props.row ? "row" : "column"};
-  align-items: ${props => props.alignItems ? props.alignItems : "center"};
-  justify-content: ${props => props.spaced ? "space-between" : "flex-start"};
-`;
+  display: ${({ row = false, column = false }) =>
+    row || column ? "flex" : "block"};
+  flex-direction: ${(props) => (props.row ? "row" : "column")};
+  align-items: ${(props) => (props.alignItems ? props.alignItems : "center")};
+  justify-content: ${(props) =>
+    props.spaced ? "space-between" : "flex-start"};
+`;

+ 51 - 0
dashboard/src/components/porter/Tag.tsx

@@ -0,0 +1,51 @@
+import React from "react";
+import styled from "styled-components";
+
+type Props = {
+  backgroundColor?: string;
+  hoverable?: boolean;
+  children: React.ReactNode;
+  borderColor?: string;
+};
+
+const Tag: React.FC<Props> = ({
+  backgroundColor,
+  hoverable = true,
+  children,
+  borderColor,
+}) => {
+  return (
+    <StyledTag
+      backgroundColor={backgroundColor ?? "#ffffff22"}
+      hoverable={hoverable}
+      hoverColor={backgroundColor ?? "#ffffff44"}
+      borderColor={borderColor}
+    >
+      {children}
+    </StyledTag>
+  );
+};
+
+export default Tag;
+
+const StyledTag = styled.div<{
+  hoverable: boolean;
+  backgroundColor: string;
+  hoverColor: string;
+  borderColor?: string;
+}>`
+  display: flex;
+  justify-content: center;
+  padding: 3px 5px;
+  border-radius: 5px;
+  background: ${({ backgroundColor }) => backgroundColor};
+  user-select: text;
+  border: 1px solid
+    ${({ borderColor, backgroundColor }) => borderColor ?? backgroundColor};
+  ${({ hoverable, hoverColor }) =>
+    hoverable &&
+    `:hover {
+  background: ${hoverColor};
+  cursor: pointer;
+}`}
+`;

+ 67 - 0
dashboard/src/lib/porter-apps/notification.ts

@@ -0,0 +1,67 @@
+import _ from "lodash";
+
+import { type PorterAppNotification } from "main/home/app-dashboard/app-view/tabs/activity-feed/events/types";
+
+import { type ClientService } from "./services";
+
+export type ClientNotification = {
+  isDeployRelated: boolean;
+  messages: PorterAppNotification[];
+  timestamp: string;
+  id: string;
+  appRevisionId: string;
+  service: ClientService;
+};
+
+export function deserializeNotifications(
+  notifications: PorterAppNotification[],
+  clientServices: ClientService[]
+): ClientNotification[] {
+  const notificationsGroupedByService = _.groupBy(
+    notifications,
+    (notification) => notification.service_name
+  );
+
+  const clientNotifications = clientServices
+    .filter((svc) => notificationsGroupedByService[svc.name.value] != null)
+    .map((svc) => {
+      const serviceName = svc.name.value;
+      const messages = orderNotificationsByTimestamp(
+        notificationsGroupedByService[serviceName],
+        "asc"
+      );
+      const timestamp = messages[0].timestamp;
+      const id = messages[0].id;
+      return {
+        // if the deployment is PENDING for any of the notifications, assume that they are all related to the failing deployment
+        // if not, then the deployment has already occurred
+        isDeployRelated: notificationsGroupedByService[serviceName].some(
+          (notification) =>
+            notification.deployment.status === "PENDING" ||
+            notification.deployment.status === "FAILURE"
+        ),
+        timestamp,
+        id,
+        messages,
+        appRevisionId: messages[0].app_revision_id,
+        service: svc,
+      };
+    });
+
+  return orderNotificationsByTimestamp(clientNotifications, "asc");
+}
+
+const orderNotificationsByTimestamp = <T extends Array<{ timestamp: string }>>(
+  notifications: T,
+  sortOrder: "asc" | "desc"
+): T => {
+  return notifications.sort((a, b) => {
+    const aTimestamp = new Date(a.timestamp);
+    const bTimestamp = new Date(b.timestamp);
+    if (sortOrder === "asc") {
+      return aTimestamp.getTime() - bTimestamp.getTime();
+    } else {
+      return bTimestamp.getTime() - aTimestamp.getTime();
+    }
+  });
+};

+ 42 - 2
dashboard/src/main/home/app-dashboard/app-view/AppDataContainer.tsx

@@ -13,6 +13,7 @@ import _ from "lodash";
 import AnimateHeight from "react-animate-height";
 import { FormProvider, useForm } from "react-hook-form";
 import { useHistory } from "react-router";
+import styled from "styled-components";
 import { match } from "ts-pattern";
 import { z } from "zod";
 
@@ -20,7 +21,9 @@ import Banner from "components/porter/Banner";
 import Button from "components/porter/Button";
 import { Error as ErrorComponent } from "components/porter/Error";
 import Icon from "components/porter/Icon";
+import Link from "components/porter/Link";
 import Spacer from "components/porter/Spacer";
+import Tag from "components/porter/Tag";
 import TabSelector from "components/TabSelector";
 import { useAppAnalytics } from "lib/hooks/useAppAnalytics";
 import { useAppValidation } from "lib/hooks/useAppValidation";
@@ -34,6 +37,7 @@ import {
 
 import api from "shared/api";
 import { Context } from "shared/Context";
+import alert from "assets/alert-warning.svg";
 import save from "assets/save-01.svg";
 
 import ConfirmRedeployModal from "./ConfirmRedeployModal";
@@ -48,6 +52,7 @@ import ImageSettingsTab from "./tabs/ImageSettingsTab";
 import JobsTab from "./tabs/JobsTab";
 import LogsTab from "./tabs/LogsTab";
 import MetricsTab from "./tabs/MetricsTab";
+import Notifications from "./tabs/Notifications";
 import Overview from "./tabs/Overview";
 import Settings from "./tabs/Settings";
 
@@ -67,6 +72,7 @@ const validTabs = [
   "helm-overrides",
   "helm-values",
   "job-history",
+  "notifications",
 ] as const;
 const DEFAULT_TAB = "activity";
 type ValidTab = (typeof validTabs)[number];
@@ -99,6 +105,7 @@ const AppDataContainer: React.FC<AppDataContainerProps> = ({ tabParam }) => {
     servicesFromYaml,
     appEnv,
     setPreviewRevision,
+    latestNotifications,
   } = useLatestRevision();
   const { validateApp } = useAppValidation({
     deploymentTargetID: deploymentTarget.id,
@@ -400,7 +407,7 @@ const AppDataContainer: React.FC<AppDataContainerProps> = ({ tabParam }) => {
         } else if (appErrors.includes("services")) {
           errorMessage = "Service settings are not properly configured";
           if (
-            errors.app?.services?.root?.message ||
+            errors.app?.services?.root?.message ??
             errors.app?.services?.message
           ) {
             const serviceErrorMessage =
@@ -435,7 +442,33 @@ const AppDataContainer: React.FC<AppDataContainerProps> = ({ tabParam }) => {
   }, [isSubmitting, JSON.stringify(errors)]);
 
   const tabs = useMemo(() => {
+    const numNotifications = latestNotifications.length;
+
     const base = [
+      {
+        label: `Notifications`,
+        value: "notifications",
+        sibling:
+          numNotifications > 0 ? (
+            <Tag borderColor={"#FFBF00"}>
+              <Link
+                to={`/apps/${latestProto.name}/notifications`}
+                color={"#FFBF00"}
+              >
+                <TagIcon src={alert} />
+                <div
+                  style={{
+                    display: "flex",
+                    alignItems: "center",
+                    fontSize: "13px",
+                  }}
+                >
+                  {numNotifications}
+                </div>
+              </Link>
+            </Tag>
+          ) : undefined,
+      },
       { label: "Activity", value: "activity" },
       { label: "Overview", value: "overview" },
       { label: "Logs", value: "logs" },
@@ -468,7 +501,7 @@ const AppDataContainer: React.FC<AppDataContainerProps> = ({ tabParam }) => {
 
     base.push({ label: "Settings", value: "settings" });
     return base;
-  }, [deploymentTarget.preview, latestProto.build]);
+  }, [deploymentTarget.preview, latestProto.build, latestNotifications.length]);
 
   useEffect(() => {
     const newProto = previewRevision
@@ -588,6 +621,7 @@ const AppDataContainer: React.FC<AppDataContainerProps> = ({ tabParam }) => {
             />
           ))
           .with("helm-values", () => <HelmLatestValuesTab />)
+          .with("notifications", () => <Notifications />)
           .otherwise(() => null)}
         <Spacer y={2} />
       </form>
@@ -604,3 +638,9 @@ const AppDataContainer: React.FC<AppDataContainerProps> = ({ tabParam }) => {
 };
 
 export default AppDataContainer;
+
+const TagIcon = styled.img`
+  height: 13px;
+  margin-right: 3px;
+  margin-top: 1px;
+`;

+ 40 - 9
dashboard/src/main/home/app-dashboard/app-view/LatestRevisionContext.tsx

@@ -18,6 +18,10 @@ import Spacer from "components/porter/Spacer";
 import Text from "components/porter/Text";
 import { usePorterYaml } from "lib/hooks/usePorterYaml";
 import { clientAppFromProto, type SourceOptions } from "lib/porter-apps";
+import {
+  deserializeNotifications,
+  type ClientNotification,
+} from "lib/porter-apps/notification";
 import {
   type ClientService,
   type DetectedServices,
@@ -30,6 +34,7 @@ import {
   useDeploymentTarget,
   type DeploymentTarget,
 } from "shared/DeploymentTargetContext";
+import { valueExists } from "shared/util";
 import notFound from "assets/not-found.png";
 
 import {
@@ -37,11 +42,13 @@ import {
   type PopulatedEnvGroup,
 } from "../validate-apply/app-settings/types";
 import { porterAppValidator, type PorterAppRecord } from "./AppView";
+import { porterAppNotificationEventMetadataValidator } from "./tabs/activity-feed/events/types";
 
 type LatestRevisionContextType = {
   porterApp: PorterAppRecord;
   latestRevision: AppRevision;
   latestProto: PorterApp;
+  latestNotifications: ClientNotification[];
   servicesFromYaml: DetectedServices | null;
   clusterId: number;
   projectId: number;
@@ -54,12 +61,13 @@ type LatestRevisionContextType = {
   latestClientServices: ClientService[];
 };
 
-export const LatestRevisionContext =
-  createContext<LatestRevisionContextType | null>(null);
+const LatestRevisionContext = createContext<LatestRevisionContextType | null>(
+  null
+);
 
 export const useLatestRevision = (): LatestRevisionContextType => {
   const context = useContext(LatestRevisionContext);
-  if (context === null) {
+  if (context == null) {
     throw new Error(
       "useLatestRevision must be used within a LatestRevisionContext"
     );
@@ -113,7 +121,13 @@ export const LatestRevisionProvider: React.FC<LatestRevisionProviderProps> = ({
     }
   );
 
-  const { data: latestRevision, status } = useQuery(
+  const {
+    data: {
+      app_revision: latestRevision,
+      notifications: latestPorterAppNotifications = [],
+    } = {},
+    status,
+  } = useQuery(
     [
       "getLatestRevision",
       currentProject?.id,
@@ -123,7 +137,7 @@ export const LatestRevisionProvider: React.FC<LatestRevisionProviderProps> = ({
     ],
     async () => {
       if (!appParamsExist) {
-        return;
+        return { app_revision: undefined, notifications: [] };
       }
       const res = await api.getLatestRevision(
         "<token>",
@@ -137,12 +151,19 @@ export const LatestRevisionProvider: React.FC<LatestRevisionProviderProps> = ({
         }
       );
 
-      const revisionData = await z
+      const {
+        app_revision: appRevision,
+        notifications: porterAppNotifications,
+      } = await z
         .object({
           app_revision: appRevisionValidator,
+          notifications: z.array(porterAppNotificationEventMetadataValidator),
         })
         .parseAsync(res.data);
-      return revisionData.app_revision;
+      return {
+        app_revision: appRevision,
+        notifications: porterAppNotifications,
+      };
     },
     {
       enabled: appParamsExist,
@@ -281,14 +302,23 @@ export const LatestRevisionProvider: React.FC<LatestRevisionProviderProps> = ({
     if (!latestProto) {
       return [];
     }
-
     const app = clientAppFromProto({
       proto: latestProto,
       overrides: detectedServices,
     });
-    return app.services;
+    return [
+      ...app.services,
+      app.predeploy?.length ? app.predeploy[0] : undefined,
+    ].filter(valueExists);
   }, [latestProto, detectedServices]);
 
+  const latestNotifications = useMemo(() => {
+    return deserializeNotifications(
+      latestPorterAppNotifications,
+      latestClientServices
+    );
+  }, [latestPorterAppNotifications, latestClientServices]);
+
   if (
     status === "loading" ||
     porterAppStatus === "loading" ||
@@ -326,6 +356,7 @@ export const LatestRevisionProvider: React.FC<LatestRevisionProviderProps> = ({
       value={{
         latestRevision,
         latestProto,
+        latestNotifications,
         porterApp,
         clusterId: currentCluster.id,
         projectId: currentProject.id,

+ 9 - 14
dashboard/src/main/home/app-dashboard/app-view/tabs/Activity.tsx

@@ -1,24 +1,19 @@
 import React from "react";
+
 import { useLatestRevision } from "../LatestRevisionContext";
 import ActivityFeed from "./activity-feed/ActivityFeed";
 
 const Activity: React.FC = () => {
-  const {
-    projectId,
-    clusterId,
-    latestProto,
-    deploymentTarget,
-  } = useLatestRevision();
+  const { projectId, clusterId, latestProto, deploymentTarget } =
+    useLatestRevision();
 
   return (
-    <>
-      <ActivityFeed
-        currentProject={projectId}
-        currentCluster={clusterId}
-        appName={latestProto.name}
-        deploymentTargetId={deploymentTarget.id}
-      />
-    </>
+    <ActivityFeed
+      currentProject={projectId}
+      currentCluster={clusterId}
+      appName={latestProto.name}
+      deploymentTargetId={deploymentTarget.id}
+    />
   );
 };
 

+ 21 - 11
dashboard/src/main/home/app-dashboard/app-view/tabs/MetricsTab.tsx

@@ -1,19 +1,29 @@
 import React from "react";
-import { useLatestRevision } from "../LatestRevisionContext";
+
 import MetricsSection from "../../validate-apply/metrics/MetricsSection";
+import { useLatestRevision } from "../LatestRevisionContext";
 
 const MetricsTab: React.FC = () => {
-    const { projectId, clusterId, appName, latestClientServices, deploymentTarget} = useLatestRevision();
+  const {
+    projectId,
+    clusterId,
+    appName,
+    latestClientServices,
+    deploymentTarget,
+  } = useLatestRevision();
 
-    return (
-        <MetricsSection
-            projectId={projectId}
-            clusterId={clusterId}
-            appName={appName}
-            services={latestClientServices}
-            deploymentTargetId={deploymentTarget.id}
-        />
-    );
+  return (
+    <MetricsSection
+      projectId={projectId}
+      clusterId={clusterId}
+      appName={appName}
+      // we do not yet support metrics for jobs
+      services={latestClientServices.filter(
+        (svc) => svc.config.type !== "predeploy" && svc.config.type !== "job"
+      )}
+      deploymentTargetId={deploymentTarget.id}
+    />
+  );
 };
 
 export default MetricsTab;

+ 28 - 0
dashboard/src/main/home/app-dashboard/app-view/tabs/Notifications.tsx

@@ -0,0 +1,28 @@
+import React from "react";
+
+import { useLatestRevision } from "../LatestRevisionContext";
+import NotificationFeed from "./notifications/NotificationFeed";
+
+const Notifications: React.FC = () => {
+  const {
+    latestNotifications,
+    projectId,
+    clusterId,
+    appName,
+    porterApp: { id: appId },
+    deploymentTarget: { id: deploymentTargetId },
+  } = useLatestRevision();
+
+  return (
+    <NotificationFeed
+      notifications={latestNotifications}
+      projectId={projectId}
+      clusterId={clusterId}
+      appName={appName}
+      deploymentTargetId={deploymentTargetId}
+      appId={appId}
+    />
+  );
+};
+
+export default Notifications;

+ 216 - 212
dashboard/src/main/home/app-dashboard/app-view/tabs/activity-feed/ActivityFeed.tsx

@@ -1,229 +1,235 @@
 import React, { useEffect, useState } from "react";
+import { useQuery } from "@tanstack/react-query";
+import axios from "axios";
+import _ from "lodash";
 import styled from "styled-components";
+import { z } from "zod";
 
-import api from "shared/api";
-
-import Text from "components/porter/Text";
-
-import EventCard from "./events/cards/EventCard";
 import Loading from "components/Loading";
-import Spacer from "components/porter/Spacer";
+import Button from "components/porter/Button";
 import Fieldset from "components/porter/Fieldset";
+import Pagination from "components/porter/Pagination";
+import Spacer from "components/porter/Spacer";
+import Text from "components/porter/Text";
 
+import api from "shared/api";
 import { feedDate } from "shared/string_utils";
-import Pagination from "components/porter/Pagination";
-import _ from "lodash";
-import Button from "components/porter/Button";
-import { PorterAppEvent, porterAppEventValidator } from "./events/types";
-import { z } from "zod";
-import { useQuery } from "@tanstack/react-query";
-import axios from "axios";
+import EventCard from "./events/cards/EventCard";
+import { porterAppEventValidator, type PorterAppEvent } from "./events/types";
 
 type Props = {
-    appName: string;
-    currentProject: number;
-    currentCluster: number;
-    deploymentTargetId: string;
+  appName: string;
+  currentProject: number;
+  currentCluster: number;
+  deploymentTargetId: string;
 };
 
 const EVENTS_POLL_INTERVAL = 5000; // poll every 5 seconds
 
-const ActivityFeed: React.FC<Props> = ({ appName, deploymentTargetId, currentCluster, currentProject }) => {
-    const [events, setEvents] = useState<PorterAppEvent[] | undefined>(undefined);
-    const [hasError, setHasError] = useState<boolean>(false);
-    const [page, setPage] = useState<number>(1);
-    const [numPages, setNumPages] = useState<number>(0);
-    const [hasPorterAgent, setHasPorterAgent] = useState<boolean | undefined>(undefined);
-    const [isPorterAgentInstalling, setIsPorterAgentInstalling] = useState(false);
-    const [shouldAnimate, setShouldAnimate] = useState(true);
-
-    // remove this filter when https://linear.app/porter/issue/POR-1676/disable-porter-agent-code-for-cpu-alerts is resolved
-    const isNotFilteredAppEvent = (event: PorterAppEvent) => {
-        return !(event.type === "APP_EVENT" &&
-            (
-                event.metadata?.short_summary?.includes("requesting more memory than is available")
-                || event.metadata?.short_summary?.includes("requesting more CPU than is available")
-                || event.metadata?.short_summary?.includes("non-zero exit code")
-            )
-        );
-    }
-
-    const { data: eventFetchData, isLoading: isEventFetchLoading, isRefetching } = useQuery(
-        ["appEvents", deploymentTargetId, page],
-        async () => {
-            const res = await api.appEvents(
-                "<token>",
-                {
-                    deployment_target_id: deploymentTargetId,
-                    page
-                },
-                {
-                    cluster_id: currentCluster,
-                    project_id: currentProject,
-                    porter_app_name: appName,
-                }
-            );
+const ActivityFeed: React.FC<Props> = ({
+  appName,
+  deploymentTargetId,
+  currentCluster,
+  currentProject,
+}) => {
+  const [events, setEvents] = useState<PorterAppEvent[] | undefined>(undefined);
+  const [page, setPage] = useState<number>(1);
+  const [numPages, setNumPages] = useState<number>(0);
+  const [hasPorterAgent, setHasPorterAgent] = useState<boolean | undefined>(
+    undefined
+  );
+  const [isPorterAgentInstalling, setIsPorterAgentInstalling] = useState(false);
 
-            const parsed = await z.object({ events: z.array(porterAppEventValidator).optional().default([]), num_pages: z.number() }).parseAsync(res.data);
-            return { events: parsed.events.filter(isNotFilteredAppEvent), pages: parsed.num_pages };
-        },
+  const {
+    data: eventFetchData,
+    isLoading: isEventFetchLoading,
+    isRefetching,
+  } = useQuery(
+    ["appEvents", deploymentTargetId, page],
+    async () => {
+      const res = await api.appEvents(
+        "<token>",
         {
-            enabled: hasPorterAgent,
-            refetchInterval: EVENTS_POLL_INTERVAL,
-        }
-    );
-    useEffect(() => {
-        if (eventFetchData || isRefetching) {
-            if (eventFetchData) {
-                setEvents(eventFetchData.events);
-                setNumPages(eventFetchData.pages);
-            }
-        }
-    }, [eventFetchData, isRefetching]);
-
-    const getLatestDeployEventIndex = () => {
-        if (events == null) {
-            return -1;
-        }
-        const deployEvents = events.filter((event) => event.type === 'DEPLOY');
-        if (deployEvents.length === 0) {
-            return -1;
-        }
-        return events.indexOf(deployEvents[0]);
-    };
-
-    const { data: porterAgentCheck, isLoading: porterAgentCheckLoading } = useQuery(
-        ["detectPorterAgent", currentProject, currentCluster],
-        async () => {
-            const res = await api.detectPorterAgent("<token>", {}, { project_id: currentProject, cluster_id: currentCluster });
-            // response will either have version key if porter agent found, or error key if not
-            const parsed = await z.object({ version: z.string().optional() }).parseAsync(res.data);
-            return parsed.version === "v3";
+          deployment_target_id: deploymentTargetId,
+          page,
         },
         {
-            enabled: !hasPorterAgent,
-            retry: (_, error) => {
-                if (axios.isAxiosError(error) && error.response?.status === 404) {
-                    setHasPorterAgent(false);
-                }
-                return false;
-            },
-            refetchOnWindowFocus: false,
+          cluster_id: currentCluster,
+          project_id: currentProject,
+          porter_app_name: appName,
         }
-    );
-    useEffect(() => {
-        if (porterAgentCheck != null) {
-            setHasPorterAgent(porterAgentCheck);
-        }
-    }, [porterAgentCheck])
-
-    const installAgent = async () => {
-        const project_id = currentProject;
-        const cluster_id = currentCluster;
-
-        setIsPorterAgentInstalling(true);
-        try {
-            await api.installPorterAgent("<token>", {}, { project_id, cluster_id });
-            window.location.reload();
-        } catch (err) {
-            setIsPorterAgentInstalling(false);
-            console.log(err);
-        }
-    };
+      );
 
-    if (isPorterAgentInstalling) {
-        return (
-            <Fieldset>
-                <Text size={16}>Installing agent...</Text>
-                <Spacer y={0.5} />
-                <Text color="helper">If you are not redirected automatically after a minute, you may need to refresh this page.</Text>
-            </Fieldset>
-        );
+      const parsed = await z
+        .object({
+          events: z.array(porterAppEventValidator).optional().default([]),
+          num_pages: z.number(),
+        })
+        .parseAsync(res.data);
+      return { events: parsed.events, pages: parsed.num_pages };
+    },
+    {
+      enabled: hasPorterAgent,
+      refetchInterval: EVENTS_POLL_INTERVAL,
     }
-
-    if (hasError) {
-        return (
-            <Fieldset>
-                <Text size={16}>Error retrieving events</Text>
-                <Spacer height="15px" />
-                <Text color="helper">An unexpected error occurred.</Text>
-            </Fieldset>
-        );
+  );
+  useEffect(() => {
+    if (eventFetchData != null || isRefetching) {
+      if (eventFetchData) {
+        setEvents(eventFetchData.events);
+        setNumPages(eventFetchData.pages);
+      }
     }
+  }, [eventFetchData, isRefetching]);
 
-    if (isEventFetchLoading || porterAgentCheckLoading || events == null) {
-        return (
-            <div>
-                <Spacer y={2} />
-                <Loading />
-            </div>
-        );
+  const getLatestDeployEventIndex = (): number => {
+    if (events == null) {
+      return -1;
+    }
+    const deployEvents = events.filter((event) => event.type === "DEPLOY");
+    if (deployEvents.length === 0) {
+      return -1;
     }
+    return events.indexOf(deployEvents[0]);
+  };
 
-    if (hasPorterAgent != null && !hasPorterAgent) {
-        return (
-            <Fieldset>
-                <Text size={16}>
-                    We couldn't detect the Porter agent on your cluster
-                </Text>
-                <Spacer y={0.5} />
-                <Text color="helper">
-                    In order to use the Activity tab, you need to install the Porter agent.
-                </Text>
-                <Spacer y={1} />
-                <Button onClick={() => installAgent()}>
-                    <I className="material-icons">add</I> Install Porter agent
-                </Button>
-            </Fieldset>
+  const { data: porterAgentCheck, isLoading: porterAgentCheckLoading } =
+    useQuery(
+      ["detectPorterAgent", currentProject, currentCluster],
+      async () => {
+        const res = await api.detectPorterAgent(
+          "<token>",
+          {},
+          { project_id: currentProject, cluster_id: currentCluster }
         );
+        // response will either have version key if porter agent found, or error key if not
+        const parsed = await z
+          .object({ version: z.string().optional() })
+          .parseAsync(res.data);
+        return parsed.version === "v3";
+      },
+      {
+        enabled: !hasPorterAgent,
+        retry: (_, error) => {
+          if (axios.isAxiosError(error) && error.response?.status === 404) {
+            setHasPorterAgent(false);
+          }
+          return false;
+        },
+        refetchOnWindowFocus: false,
+      }
+    );
+  useEffect(() => {
+    if (porterAgentCheck != null) {
+      setHasPorterAgent(porterAgentCheck);
     }
+  }, [porterAgentCheck]);
 
-    // if all the events are hidden and there's only one page, show this no-events-found message
-    // else, users should be able to go to the next page for events
-    if (events != null && events.length === 0 && numPages <= 1) { 
-        return (
-            <Fieldset>
-                <Text size={16}>No events found for "{appName}"</Text>
-                <Spacer height="15px" />
-                <Text color="helper">
-                    This application currently has no associated events.
-                </Text>
-            </Fieldset>
-        );
+  const installAgent = async (): Promise<void> => {
+    setIsPorterAgentInstalling(true);
+    try {
+      await api.installPorterAgent(
+        "<token>",
+        {},
+        { project_id: currentProject, cluster_id: currentCluster }
+      );
+      window.location.reload();
+    } catch (err) {
+      setIsPorterAgentInstalling(false);
     }
+  };
+
+  if (isPorterAgentInstalling) {
+    return (
+      <Fieldset>
+        <Text size={16}>Installing agent...</Text>
+        <Spacer y={0.5} />
+        <Text color="helper">
+          If you are not redirected automatically after a minute, you may need
+          to refresh this page.
+        </Text>
+      </Fieldset>
+    );
+  }
+
+  if (isEventFetchLoading || porterAgentCheckLoading || events == null) {
+    return (
+      <div>
+        <Spacer y={2} />
+        <Loading />
+      </div>
+    );
+  }
 
+  if (hasPorterAgent != null && !hasPorterAgent) {
     return (
-        <StyledActivityFeed shouldAnimate={shouldAnimate}>
-            {events.map((event, i) => {
-                return (
-                    <EventWrapper isLast={i === events.length - 1} key={i}>
-                        {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>
-                        </Time>
-                        <EventCard
-                            deploymentTargetId={deploymentTargetId}
-                            event={event}
-                            key={i}
-                            isLatestDeployEvent={i === getLatestDeployEventIndex()}
-                            projectId={currentProject}
-                            clusterId={currentCluster}
-                            appName={appName}
-                        />
-                    </EventWrapper>
-                );
-            })}
-            {numPages > 1 && (
-                <>
-                    <Spacer y={1} />
-                    <Pagination page={page} setPage={setPage} totalPages={numPages} />
-                </>
-            )}
-        </StyledActivityFeed>
+      <Fieldset>
+        <Text size={16}>
+          {"We couldn't detect the Porter agent on your cluster"}
+        </Text>
+        <Spacer y={0.5} />
+        <Text color="helper">
+          In order to use the Activity tab, you need to install the Porter
+          agent.
+        </Text>
+        <Spacer y={1} />
+        <Button
+          onClick={async () => {
+            await installAgent();
+          }}
+        >
+          <I className="material-icons">add</I> Install Porter agent
+        </Button>
+      </Fieldset>
     );
+  }
+
+  // if all the events are hidden and there's only one page, show this no-events-found message
+  // else, users should be able to go to the next page for events
+  if (events != null && events.length === 0 && numPages <= 1) {
+    return (
+      <Fieldset>
+        <Text size={16}>No events found for &ldquo;{appName}&rdquo;</Text>
+        <Spacer height="15px" />
+        <Text color="helper">
+          This application currently has no associated events.
+        </Text>
+      </Fieldset>
+    );
+  }
+
+  return (
+    <StyledActivityFeed>
+      {events.map((event, i) => {
+        return (
+          <EventWrapper isLast={i === events.length - 1} key={i}>
+            {i !== events.length - 1 && events.length > 1 && <Line />}
+            <Dot />
+            <Time>
+              <Text>{feedDate(event.created_at).split(", ")[0]}</Text>
+              <Spacer x={0.5} />
+              <Text>{feedDate(event.created_at).split(", ")[1]}</Text>
+            </Time>
+            <EventCard
+              deploymentTargetId={deploymentTargetId}
+              event={event}
+              key={i}
+              isLatestDeployEvent={i === getLatestDeployEventIndex()}
+              projectId={currentProject}
+              clusterId={currentCluster}
+              appName={appName}
+            />
+          </EventWrapper>
+        );
+      })}
+      {numPages > 1 && (
+        <>
+          <Spacer y={1} />
+          <Pagination page={page} setPage={setPage} totalPages={numPages} />
+        </>
+      )}
+    </StyledActivityFeed>
+  );
 };
 
 export default ActivityFeed;
@@ -233,26 +239,26 @@ const I = styled.i`
   margin-right: 5px;
 `;
 
-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;"}
+const Time = styled.div`
+  opacity: 0;
+  animation: fadeIn 0.3s 0.1s;
+  animation-fill-mode: forwards;
   width: 90px;
 `;
 
-const Line = styled.div<{ shouldAnimate: boolean }>`
+const Line = styled.div`
   width: 1px;
   height: calc(100% + 30px);
   background: #414141;
   position: absolute;
   left: 3px;
   top: 36px;
-  opacity: ${(props) => props.shouldAnimate ? "0" : "1"};
-  ${(props) => props.shouldAnimate && "animation: fadeIn 0.3s 0.1s;"}
-  ${(props) => props.shouldAnimate && "animation-fill-mode: forwards;"}
+  opacity: 0;
+  animation: fadeIn 0.3s 0.1s;
+  animation-fill-mode: forwards;
 `;
 
-const Dot = styled.div<{ shouldAnimate: boolean }>`
+const Dot = styled.div`
   width: 7px;
   height: 7px;
   background: #fff;
@@ -260,14 +266,12 @@ const Dot = styled.div<{ shouldAnimate: boolean }>`
   margin-left: -29px;
   margin-right: 20px;
   z-index: 1;
-  opacity: ${(props) => props.shouldAnimate ? "0" : "1"};
-  ${(props) => props.shouldAnimate && "animation: fadeIn 0.3s 0.1s;"}
-  ${(props) => props.shouldAnimate && "animation-fill-mode: forwards;"}
+  opacity: 0;
+  animation: fadeIn 0.3s 0.1s;
+  animation-fill-mode: forwards;
 `;
 
-const EventWrapper = styled.div<{
-    isLast: boolean;
-}>`
+const EventWrapper = styled.div<{ isLast: boolean }>`
   padding-left: 30px;
   display: flex;
   align-items: center;
@@ -275,9 +279,9 @@ const EventWrapper = styled.div<{
   margin-bottom: ${(props) => (props.isLast ? "" : "25px")};
 `;
 
-const StyledActivityFeed = styled.div<{ shouldAnimate: boolean }>`
+const StyledActivityFeed = styled.div`
   width: 100%;
-  ${(props) => props.shouldAnimate && "animation: fadeIn 0.3s 0s;"}
+  animation: fadeIn 0.3s 0s;
   @keyframes fadeIn {
     from {
       opacity: 0;

+ 81 - 64
dashboard/src/main/home/app-dashboard/app-view/tabs/activity-feed/events/cards/BuildEventCard.tsx

@@ -1,23 +1,34 @@
 import React from "react";
 import styled from "styled-components";
+import { match } from "ts-pattern";
 
-import build from "assets/build.png";
-
-import run_for from "assets/run_for.png";
-import refresh from "assets/refresh.png";
-
-import Text from "components/porter/Text";
 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 { getDuration, getStatusColor, getStatusIcon, triggerWorkflow } from '../utils';
-import { Code, ImageTagContainer, CommitIcon, StyledEventCard } from "./EventCard";
+import Link from "components/porter/Link";
+import Spacer from "components/porter/Spacer";
+import Tag from "components/porter/Tag";
+import Text from "components/porter/Text";
+import { type PorterAppRecord } from "main/home/app-dashboard/app-view/AppView";
+
+import build from "assets/build.png";
 import document from "assets/document.svg";
-import { type PorterAppBuildEvent } from "../types";
-import { match } from "ts-pattern";
 import pull_request_icon from "assets/pull_request_icon.svg";
-import { type PorterAppRecord } from "main/home/app-dashboard/app-view/AppView";
+import refresh from "assets/refresh.png";
+import run_for from "assets/run_for.png";
+
+import { type PorterAppBuildEvent } from "../types";
+import {
+  getDuration,
+  getStatusColor,
+  getStatusIcon,
+  triggerWorkflow,
+} from "../utils";
+import {
+  Code,
+  CommitIcon,
+  ImageTagContainer,
+  StyledEventCard,
+} from "./EventCard";
 
 type Props = {
   event: PorterAppBuildEvent;
@@ -29,16 +40,16 @@ type Props = {
   porterApp: PorterAppRecord;
 };
 
-const BuildEventCard: React.FC<Props> = ({ 
-  event, 
-  appName, 
-  projectId, 
+const BuildEventCard: React.FC<Props> = ({
+  event,
+  appName,
+  projectId,
   clusterId,
   gitCommitUrl,
-  displayCommitSha, 
+  displayCommitSha,
   porterApp,
 }) => {
-  const renderStatusText = (event: PorterAppBuildEvent) => {
+  const renderStatusText = (event: PorterAppBuildEvent): JSX.Element => {
     const color = getStatusColor(event.status);
     return (
       <StatusContainer color={color}>
@@ -46,39 +57,40 @@ const BuildEventCard: React.FC<Props> = ({
           .with("SUCCESS", () => "Build successful")
           .with("FAILED", () => "Build failed")
           .with("CANCELED", () => "Build canceled")
-          .otherwise(() => "Build in progress...")
-        }
+          .otherwise(() => "Build in progress...")}
       </StatusContainer>
     );
   };
 
-  const renderLogsAndRetry = (event: PorterAppBuildEvent) => {
+  const renderLogsAndRetry = (event: PorterAppBuildEvent): JSX.Element => {
     return (
-        <Wrapper>
-          <Link to={`/apps/${appName}/events?event_id=${event.id}`} hasunderline>
-            <Container row>
-              <Icon src={document} height="10px" />
-              <Spacer inline width="5px" />
-              View logs
-            </Container>
+      <Container row>
+        <Tag>
+          <Link to={`/apps/${appName}/events?event_id=${event.id}`}>
+            <TagIcon src={document} />
+            Logs
           </Link>
-          <Spacer inline x={1} />
-          <Link hasunderline onClick={async () => { await triggerWorkflow({
-            projectId,
-            clusterId,
-            porterApp,
-          }); }}>
-            <Container row>
-              <Icon height="10px" src={refresh} />
-              <Spacer inline width="5px" />
-              Retry
-            </Container>
+        </Tag>
+        <Spacer inline x={0.5} />
+        <Tag>
+          <Link
+            onClick={async () => {
+              await triggerWorkflow({
+                projectId,
+                clusterId,
+                porterApp,
+              });
+            }}
+          >
+            <TagIcon src={refresh} />
+            Retry
           </Link>
-        </Wrapper>
+        </Tag>
+      </Container>
     );
-  }
+  };
 
-  const renderInfoCta = (event: PorterAppBuildEvent) => {
+  const renderInfoCta = (event: PorterAppBuildEvent): JSX.Element | null => {
     switch (event.status) {
       case "SUCCESS":
         return null;
@@ -88,16 +100,18 @@ const BuildEventCard: React.FC<Props> = ({
         return renderLogsAndRetry(event);
       default:
         return (
-          <Wrapper>
-            <Link
-              hasunderline
-              target="_blank"
-              to={`https://github.com/${porterApp.repo_name}/actions/runs/${event.metadata.action_run_id}`}
-            >
-              View live logs
-            </Link>
-            <Spacer inline x={1} />
-          </Wrapper>
+          <Container row>
+            <Tag>
+              <Link
+                target="_blank"
+                to={`https://github.com/${porterApp.repo_name}/actions/runs/${event.metadata.action_run_id}`}
+                showTargetBlankIcon={false}
+              >
+                <TagIcon src={document} />
+                Live logs
+              </Link>
+            </Tag>
+          </Container>
         );
     }
   };
@@ -109,17 +123,21 @@ const BuildEventCard: React.FC<Props> = ({
           <Icon height="16px" src={build} />
           <Spacer inline width="10px" />
           <Text>Application build</Text>
-          {gitCommitUrl && displayCommitSha &&
+          {gitCommitUrl && displayCommitSha && (
             <>
               <Spacer inline x={0.5} />
               <ImageTagContainer>
-                <Link to={gitCommitUrl} target="_blank" showTargetBlankIcon={false}>
+                <Link
+                  to={gitCommitUrl}
+                  target="_blank"
+                  showTargetBlankIcon={false}
+                >
                   <CommitIcon src={pull_request_icon} />
                   <Code>{displayCommitSha}</Code>
                 </Link>
-              </ImageTagContainer> 
+              </ImageTagContainer>
             </>
-          }
+          )}
         </Container>
         <Container row>
           <Icon height="14px" src={run_for} />
@@ -144,15 +162,14 @@ const BuildEventCard: React.FC<Props> = ({
 
 export default BuildEventCard;
 
-const Wrapper = styled.div`
-  display: flex;
-  height: 20px;
-  margin-top: -3px;
-`;
-
 const StatusContainer = styled.div<{ color: string }>`
   display: flex;
   align-items: center;
-  color: ${props => props.color};
+  color: ${(props) => props.color};
   font-size: 13px;
 `;
+
+const TagIcon = styled.img`
+  height: 12px;
+  margin-right: 3px;
+`;

+ 25 - 19
dashboard/src/main/home/app-dashboard/app-view/tabs/activity-feed/events/cards/DeployEventCard.tsx

@@ -7,12 +7,15 @@ import Container from "components/porter/Container";
 import Icon from "components/porter/Icon";
 import Link from "components/porter/Link";
 import Spacer from "components/porter/Spacer";
+import Tag from "components/porter/Tag";
 import Text from "components/porter/Text";
 import { useLatestRevision } from "main/home/app-dashboard/app-view/LatestRevisionContext";
 import { useRevisionList } from "lib/hooks/useRevisionList";
 
 import api from "shared/api";
 import deploy from "assets/deploy.png";
+import view_changes from "assets/edit-contained.svg";
+import revert from "assets/fast-backward.svg";
 import pull_request_icon from "assets/pull_request_icon.svg";
 import run_for from "assets/run_for.png";
 
@@ -32,6 +35,7 @@ type Props = {
   clusterId: number;
   gitCommitUrl: string;
   displayCommitSha: string;
+  isMostRecentDeployEvent: boolean;
 };
 
 const DeployEventCard: React.FC<Props> = ({
@@ -195,14 +199,16 @@ const DeployEventCard: React.FC<Props> = ({
     const baseRevisionId = numberToRevisionId[baseRevisionNumber];
     return (
       <>
-        <Link
-          hasunderline
-          onClick={() => {
-            setDiffModalVisible(true);
-          }}
-        >
-          View changes
-        </Link>
+        <Tag>
+          <Link
+            onClick={() => {
+              setDiffModalVisible(true);
+            }}
+          >
+            <TagIcon src={view_changes} />
+            View changes
+          </Link>
+        </Tag>
         {diffModalVisible && (
           <RevisionDiffModal
             base={{
@@ -294,9 +300,8 @@ const DeployEventCard: React.FC<Props> = ({
           {isRevertable && (
             <>
               <Spacer inline x={1} />
-              <TempWrapper>
+              <Tag>
                 <Link
-                  hasunderline
                   onClick={() => {
                     setRevertData({
                       revisionNumber:
@@ -304,12 +309,12 @@ const DeployEventCard: React.FC<Props> = ({
                       id: event.metadata.app_revision_id,
                     });
                   }}
-                  color="#6e9df5"
                 >
+                  <TagIcon src={revert} />
                   Revert to version{" "}
-                  {revisionIdToNumber[event.metadata.app_revision_id]}
+                  {revisionIdToNumber[event.metadata.app_revision_id]}{" "}
                 </Link>
-              </TempWrapper>
+              </Tag>
             </>
           )}
           <Spacer inline x={0.5} />
@@ -324,7 +329,8 @@ const DeployEventCard: React.FC<Props> = ({
               event.metadata.service_deployment_metadata
             }
             appName={appName}
-            revision={revisionIdToNumber[event.metadata.app_revision_id]}
+            revisionNumber={revisionIdToNumber[event.metadata.app_revision_id]}
+            revisionId={event.metadata.app_revision_id}
           />
         </AnimateHeight>
       )}
@@ -344,11 +350,6 @@ const DeployEventCard: React.FC<Props> = ({
 
 export default DeployEventCard;
 
-// TODO: remove after fixing v-align
-const TempWrapper = styled.div`
-  margin-top: -3px;
-`;
-
 const Code = styled.span`
   font-family: monospace;
 `;
@@ -378,3 +379,8 @@ const StatusTextContainer = styled.div`
   align-items: center;
   flex-direction: row;
 `;
+
+const TagIcon = styled.img`
+  height: 12px;
+  margin-right: 3px;
+`;

+ 22 - 18
dashboard/src/main/home/app-dashboard/app-view/tabs/activity-feed/events/cards/EventCard.tsx

@@ -1,13 +1,13 @@
 import React, { useMemo } from "react";
 import styled from "styled-components";
+import { match } from "ts-pattern";
+
+import { useLatestRevision } from "main/home/app-dashboard/app-view/LatestRevisionContext";
 
+import { type PorterAppEvent } from "../types";
 import BuildEventCard from "./BuildEventCard";
-import PreDeployEventCard from "./PreDeployEventCard";
-import AppEventCard from "./AppEventCard";
 import DeployEventCard from "./DeployEventCard";
-import { PorterAppEvent } from "../types";
-import { match } from "ts-pattern";
-import { useLatestRevision } from "main/home/app-dashboard/app-view/LatestRevisionContext";
+import PreDeployEventCard from "./PreDeployEventCard";
 
 type Props = {
   event: PorterAppEvent;
@@ -18,7 +18,14 @@ type Props = {
   isLatestDeployEvent?: boolean;
 };
 
-const EventCard: React.FC<Props> = ({ event, deploymentTargetId, isLatestDeployEvent, projectId, clusterId, appName }) => {
+const EventCard: React.FC<Props> = ({
+  event,
+  deploymentTargetId,
+  isLatestDeployEvent,
+  projectId,
+  clusterId,
+  appName,
+}) => {
   const { porterApp } = useLatestRevision();
 
   const gitCommitUrl = useMemo(() => {
@@ -28,6 +35,7 @@ const EventCard: React.FC<Props> = ({ event, deploymentTargetId, isLatestDeployE
 
     return match(event)
       .with({ type: "APP_EVENT" }, () => "")
+      .with({ type: "NOTIFICATION" }, () => "")
       .with({ type: "BUILD" }, (event) =>
         event.metadata.commit_sha
           ? `https://www.github.com/${porterApp.repo_name}/commit/${event.metadata.commit_sha}`
@@ -44,7 +52,7 @@ const EventCard: React.FC<Props> = ({ event, deploymentTargetId, isLatestDeployE
           : ""
       )
       .exhaustive();
-  }, [JSON.stringify(event), porterApp])
+  }, [JSON.stringify(event), porterApp]);
 
   const displayCommitSha = useMemo(() => {
     if (!porterApp.repo_name) {
@@ -53,6 +61,7 @@ const EventCard: React.FC<Props> = ({ event, deploymentTargetId, isLatestDeployE
 
     return match(event)
       .with({ type: "APP_EVENT" }, () => "")
+      .with({ type: "NOTIFICATION" }, () => "")
       .with({ type: "BUILD" }, (event) =>
         event.metadata.commit_sha ? event.metadata.commit_sha.slice(0, 7) : ""
       )
@@ -66,15 +75,8 @@ const EventCard: React.FC<Props> = ({ event, deploymentTargetId, isLatestDeployE
   }, [JSON.stringify(event), porterApp]);
 
   return match(event)
-    .with({ type: "APP_EVENT" }, (ev) => (
-      <AppEventCard
-        event={ev}
-        deploymentTargetId={deploymentTargetId}
-        projectId={projectId}
-        clusterId={clusterId}
-        appName={appName}
-      />
-    ))
+    .with({ type: "APP_EVENT" }, () => null) // we do not show app events in the activity feed, we convert them to notifications
+    .with({ type: "NOTIFICATION" }, () => null) // we do not show notifications in the activity feed, rather in the notifications tab
     .with({ type: "BUILD" }, (ev) => (
       <BuildEventCard
         event={ev}
@@ -117,7 +119,7 @@ export const StyledEventCard = styled.div<{ row?: boolean }>`
   width: 100%;
   padding: 15px;
   display: flex;
-  flex-direction: ${({ row }) => row ? "row" : "column"};
+  flex-direction: ${({ row }) => (row ? "row" : "column")};
   justify-content: space-between;
   border-radius: 5px;
   background: ${({ theme }) => theme.fg};
@@ -155,7 +157,9 @@ export const ImageTagContainer = styled.div<{ hoverable?: boolean }>`
   border-radius: 5px;
   background: #ffffff22;
   user-select: text;
-  ${({hoverable = true}) => hoverable && `:hover {
+  ${({ hoverable = true }) =>
+    hoverable &&
+    `:hover {
     background: #ffffff44;
     cursor: pointer;
   }`}

+ 90 - 50
dashboard/src/main/home/app-dashboard/app-view/tabs/activity-feed/events/cards/PreDeployEventCard.tsx

@@ -1,24 +1,35 @@
-import React from "react";
+import React, { useMemo } from "react";
 import styled from "styled-components";
+import { match } from "ts-pattern";
 
-import pre_deploy from "assets/pre_deploy.png";
-
-import run_for from "assets/run_for.png";
-import refresh from "assets/refresh.png";
-
-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 { getDuration, getStatusColor, getStatusIcon, triggerWorkflow } from '../utils';
-import { Code, ImageTagContainer, CommitIcon, StyledEventCard } from "./EventCard";
 import Link from "components/porter/Link";
-import document from "assets/document.svg";
-import { PorterAppPreDeployEvent } from "../types";
+import Spacer from "components/porter/Spacer";
+import Tag from "components/porter/Tag";
+import Text from "components/porter/Text";
 import { useLatestRevision } from "main/home/app-dashboard/app-view/LatestRevisionContext";
+
+import alert from "assets/alert-warning.svg";
+import document from "assets/document.svg";
+import pre_deploy from "assets/pre_deploy.png";
 import pull_request_icon from "assets/pull_request_icon.svg";
-import { match } from "ts-pattern";
+import refresh from "assets/refresh.png";
+import run_for from "assets/run_for.png";
+
+import { type PorterAppPreDeployEvent } from "../types";
+import {
+  getDuration,
+  getStatusColor,
+  getStatusIcon,
+  triggerWorkflow,
+} from "../utils";
+import {
+  Code,
+  CommitIcon,
+  ImageTagContainer,
+  StyledEventCard,
+} from "./EventCard";
 
 type Props = {
   event: PorterAppPreDeployEvent;
@@ -29,26 +40,35 @@ type Props = {
   displayCommitSha: string;
 };
 
-const PreDeployEventCard: React.FC<Props> = ({ 
+const PreDeployEventCard: React.FC<Props> = ({
   event,
   appName,
-  projectId, 
+  projectId,
   clusterId,
   gitCommitUrl,
-  displayCommitSha, 
+  displayCommitSha,
 }) => {
-  const { porterApp } = useLatestRevision();
+  const { porterApp, latestNotifications } = useLatestRevision();
 
-  const renderStatusText = (event: PorterAppPreDeployEvent) => {
+  const renderStatusText = (event: PorterAppPreDeployEvent): JSX.Element => {
     const color = getStatusColor(event.status);
     const text = match(event.status)
       .with("SUCCESS", () => "Pre-deploy successful")
       .with("FAILED", () => "Pre-deploy failed")
       .with("CANCELED", () => "Pre-deploy canceled")
-      .otherwise(() => "Pre-deploy  in progress...")
+      .otherwise(() => "Pre-deploy  in progress...");
     return <Text color={color}>{text}</Text>;
   };
 
+  const predeployNotificationsExist = useMemo(() => {
+    return latestNotifications.some((notification) => {
+      return (
+        notification.service.config.type === "predeploy" &&
+        notification.appRevisionId === event.metadata.app_revision_id
+      );
+    });
+  }, [JSON.stringify(latestNotifications)]);
+
   return (
     <StyledEventCard>
       <Container row spaced>
@@ -56,17 +76,21 @@ const PreDeployEventCard: React.FC<Props> = ({
           <Icon height="16px" src={pre_deploy} />
           <Spacer inline width="10px" />
           <Text>Application pre-deploy</Text>
-          {gitCommitUrl && displayCommitSha &&
+          {gitCommitUrl && displayCommitSha && (
             <>
               <Spacer inline x={0.5} />
               <ImageTagContainer>
-                <Link to={gitCommitUrl} target="_blank" showTargetBlankIcon={false}>
+                <Link
+                  to={gitCommitUrl}
+                  target="_blank"
+                  showTargetBlankIcon={false}
+                >
                   <CommitIcon src={pull_request_icon} />
                   <Code>{displayCommitSha}</Code>
                 </Link>
-              </ImageTagContainer> 
+              </ImageTagContainer>
             </>
-          }
+          )}
         </Container>
         <Container row>
           <Icon height="14px" src={run_for} />
@@ -81,31 +105,46 @@ const PreDeployEventCard: React.FC<Props> = ({
           <Spacer inline width="10px" />
           {renderStatusText(event)}
           <Spacer inline x={1} />
-          <Wrapper>
-            <Link to={`/apps/${appName}/events?event_id=${event.id}&service=predeploy&revision_id=${event.metadata.app_revision_id}`} hasunderline>
-              <Container row>
-                <Icon src={document} height="10px" />
-                <Spacer inline width="5px" />
-                View logs
-              </Container>
+          <Tag>
+            <Link
+              to={`/apps/${appName}/events?event_id=${event.id}&service=predeploy&revision_id=${event.metadata.app_revision_id}`}
+            >
+              <TagIcon src={document} />
+              Logs
             </Link>
-            {(event.status !== "SUCCESS") &&
-              <>
-                <Spacer inline x={1} />
-                <Link hasunderline onClick={() => triggerWorkflow({
-                  projectId,
-                  clusterId,
-                  porterApp,
-                })}>
-                  <Container row>
-                    <Icon height="10px" src={refresh} />
-                    <Spacer inline width="5px" />
-                    Retry
-                  </Container>
+          </Tag>
+          {event.status !== "SUCCESS" && (
+            <>
+              <Spacer inline x={0.5} />
+              <Tag>
+                <Link
+                  onClick={async () => {
+                    await triggerWorkflow({
+                      projectId,
+                      clusterId,
+                      porterApp,
+                    });
+                  }}
+                >
+                  <TagIcon src={refresh} />
+                  Retry
                 </Link>
-              </>}
-          </Wrapper>
-          <Spacer inline x={1} />
+              </Tag>
+            </>
+          )}
+          {predeployNotificationsExist && (
+            <>
+              <Spacer inline x={0.5} />
+              <Container row>
+                <Tag borderColor="#FFBF00">
+                  <Link to={`/apps/${appName}/notifications`} color={"#FFBF00"}>
+                    <TagIcon src={alert} />
+                    Notifications
+                  </Link>
+                </Tag>
+              </Container>
+            </>
+          )}
         </Container>
       </Container>
     </StyledEventCard>
@@ -114,6 +153,7 @@ const PreDeployEventCard: React.FC<Props> = ({
 
 export default PreDeployEventCard;
 
-const Wrapper = styled.div`
-  margin-top: -3px;
-`;
+const TagIcon = styled.img`
+  height: 12px;
+  margin-right: 3px;
+`;

+ 168 - 131
dashboard/src/main/home/app-dashboard/app-view/tabs/activity-feed/events/cards/ServiceStatusDetail.tsx

@@ -1,138 +1,165 @@
-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 { Service } from 'main/home/app-dashboard/new-app-flow/serviceTypes';
-import { useLatestRevision } from 'main/home/app-dashboard/app-view/LatestRevisionContext';
-import { match } from 'ts-pattern';
+import React from "react";
+import styled from "styled-components";
+import { match } from "ts-pattern";
+
+import Icon from "components/porter/Icon";
+import Link from "components/porter/Link";
+import Spacer from "components/porter/Spacer";
+import Tag from "components/porter/Tag";
+import Text from "components/porter/Text";
+import { useLatestRevision } from "main/home/app-dashboard/app-view/LatestRevisionContext";
+import { Service } from "main/home/app-dashboard/new-app-flow/serviceTypes";
+
+import alert from "assets/alert-warning.svg";
+import metrics from "assets/bar-group-03.svg";
+import calendar from "assets/calendar-02.svg";
+import document from "assets/document.svg";
+import link from "assets/external-link.svg";
+import job from "assets/job.png";
 import web from "assets/web.png";
 import worker from "assets/worker.png";
-import job from "assets/job.png";
+
+import { getStatusColor, getStatusIcon } from "../utils";
 
 type Props = {
-    serviceDeploymentMetadata: Record<string, {
-        status: string;
-        type: string;
-    }>;
-    appName: string;
-    revision: number;
-}
+  serviceDeploymentMetadata: Record<
+    string,
+    {
+      status: string;
+      type: string;
+    }
+  >;
+  appName: string;
+  revisionId: string;
+  revisionNumber: number;
+};
 
 const ServiceStatusDetail: React.FC<Props> = ({
-    serviceDeploymentMetadata,
-    appName,
-    revision,
+  serviceDeploymentMetadata,
+  appName,
+  revisionId,
+  revisionNumber,
 }) => {
-    const { latestClientServices } = useLatestRevision();
-    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";
-        }
-    };
+  const { latestClientServices, latestNotifications } = useLatestRevision();
+  const convertEventStatusToCopy = (status: string): 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 { status: serviceStatus, type: serviceType } = serviceDeploymentMetadata[key];
-                    const service = latestClientServices.find((s) => s.name.value === key);
-                    const externalUri = service != null && service.config.type === "web" && service.config.domains.length ? service.config.domains[0].name.value : "";
-                    return (
-                        <ServiceStatusTableRow key={key}>
-                            <ServiceStatusTableData width={"200px"}>
-                                {match(serviceType)
-                                    .with("web", () => (
-                                            <Icon
-                                                src={web}
-                                                height="14px"
-                                            />
-                                    ))
-                                    .with("worker", () => (
-                                            <Icon
-                                                src={worker}
-                                                height="14px"
-                                            />
-                                    ))
-                                    .with("job", () => (
-                                            <Icon
-                                                src={job}
-                                                height="14px"
-                                            />
-                                    ))
-                                    .otherwise(() => null)
-                                }
-                                <Spacer inline x={0.5} />
-                                <Text>{key}</Text>
-                            </ServiceStatusTableData>
-                            <ServiceStatusTableData width={"120px"}>
-                                <Icon height="12px" src={getStatusIcon(serviceStatus)} />
-                                <Spacer inline x={0.5} />
-                                <Text color={getStatusColor(serviceStatus)}>{convertEventStatusToCopy(serviceStatus)}</Text>
-                            </ServiceStatusTableData>
-                            <ServiceStatusTableData showBorderRight={false}>
-                                {serviceType !== "job" &&
-                                    <>
-                                        <Link
-                                            to={`/apps/${appName}/logs?version=${revision}&service=${key}`}
-                                            hasunderline
-                                            hoverColor="#949eff"
-                                        >
-                                            Logs
-                                        </Link>
-                                        <Spacer inline x={0.5} />
-                                        <Link
-                                            to={`/apps/${appName}/metrics?service=${key}`}
-                                            hasunderline
-                                            hoverColor="#949eff"
-                                        >
-                                            Metrics
-                                        </Link>
-                                    </>
-                                }
-                                {serviceType === "job" &&
-                                    <>
-                                        <Link
-                                            to={`/apps/${appName}/job-history?service=${key}`}
-                                            hasunderline
-                                            hoverColor="#949eff"
-                                        >
-                                            History
-                                        </Link>
-                                    </>
-                                }
-                                {externalUri !== "" &&
-                                    <>
-                                        <Spacer inline x={0.5} />
-                                        <Link
-                                            to={Service.prefixSubdomain(externalUri)}
-                                            hasunderline
-                                            hoverColor="#949eff"
-                                            target={"_blank"}
-                                        >
-                                            External Link
-                                        </Link>
-                                    </>
-                                }
-                            </ServiceStatusTableData>
-                        </ServiceStatusTableRow>
-                    );
-                })}
-            </tbody>
-        </ServiceStatusTable>
-    )
-}
+  return (
+    <ServiceStatusTable>
+      <tbody>
+        {Object.keys(serviceDeploymentMetadata).map((key) => {
+          const { status: serviceStatus, type: serviceType } =
+            serviceDeploymentMetadata[key];
+          const service = latestClientServices.find(
+            (s) => s.name.value === key
+          );
+          const externalUri =
+            service != null &&
+            service.config.type === "web" &&
+            service.config.domains.length
+              ? service.config.domains[0].name.value
+              : "";
+          const notificationsExistForService = latestNotifications.some(
+            (n) =>
+              n.service.name.value === key && n.appRevisionId === revisionId
+          );
+          return (
+            <ServiceStatusTableRow key={key}>
+              <ServiceStatusTableData width={"200px"}>
+                {match(serviceType)
+                  .with("web", () => <Icon src={web} height="14px" />)
+                  .with("worker", () => <Icon src={worker} height="14px" />)
+                  .with("job", () => <Icon src={job} height="14px" />)
+                  .otherwise(() => null)}
+                <Spacer inline x={0.5} />
+                <Text>{key}</Text>
+              </ServiceStatusTableData>
+              <ServiceStatusTableData width={"120px"}>
+                <Icon height="12px" src={getStatusIcon(serviceStatus)} />
+                <Spacer inline x={0.5} />
+                <Text color={getStatusColor(serviceStatus)}>
+                  {convertEventStatusToCopy(serviceStatus)}
+                </Text>
+              </ServiceStatusTableData>
+              <ServiceStatusTableData>
+                <>
+                  {notificationsExistForService && (
+                    <>
+                      <Tag borderColor="#FFBF00">
+                        <Link
+                          to={`/apps/${appName}/notifications?service=${key}`}
+                          color={"#FFBF00"}
+                        >
+                          <TagIcon src={alert} />
+                          Notifications
+                        </Link>
+                      </Tag>
+                      <Spacer inline x={0.5} />
+                    </>
+                  )}
+                  {serviceType !== "job" && (
+                    <>
+                      <Tag>
+                        <Link
+                          to={`/apps/${appName}/logs?version=${revisionNumber}&service=${key}`}
+                        >
+                          <TagIcon src={document} />
+                          Logs
+                        </Link>
+                      </Tag>
+                      <Spacer inline x={0.5} />
+                      <Tag>
+                        <Link to={`/apps/${appName}/metrics?service=${key}`}>
+                          <TagIcon src={metrics} />
+                          Metrics
+                        </Link>
+                      </Tag>
+                    </>
+                  )}
+                  {serviceType === "job" && (
+                    <Tag>
+                      <TagIcon src={calendar} style={{ marginTop: "2px" }} />
+                      <Link to={`/apps/${appName}/job-history?service=${key}`}>
+                        History
+                      </Link>
+                    </Tag>
+                  )}
+                  {externalUri !== "" && (
+                    <>
+                      <Spacer inline x={0.5} />
+                      <Tag>
+                        <Link
+                          to={Service.prefixSubdomain(externalUri)}
+                          target={"_blank"}
+                          showTargetBlankIcon={false}
+                        >
+                          <TagIcon src={link} />
+                          External Link
+                        </Link>
+                      </Tag>
+                    </>
+                  )}
+                </>
+              </ServiceStatusTableData>
+            </ServiceStatusTableRow>
+          );
+        })}
+      </tbody>
+    </ServiceStatusTable>
+  );
+};
 
 export default ServiceStatusDetail;
 
@@ -143,13 +170,23 @@ const ServiceStatusTable = styled.table`
 
 const ServiceStatusTableRow = styled.tr`
   display: flex;
-  align-items: center;  
+  align-items: center;
+  > td:last-child {
+    border-right: none;
+  }
 `;
 
-const ServiceStatusTableData = styled.td<{ width?: string; showBorderRight?: boolean }>`
+const ServiceStatusTableData = styled.td<{
+  width?: string;
+}>`
   padding: 8px 10px;
   display: flex;
   align-items: center;
+  border-right: 2px solid #ffffff11;
   ${(props) => props.width && `width: ${props.width};`}
-  ${({ showBorderRight = true }) => showBorderRight && `border-right: 2px solid #ffffff11;`}
-`;
+`;
+
+const TagIcon = styled.img`
+  height: 12px;
+  margin-right: 3px;
+`;

+ 117 - 63
dashboard/src/main/home/app-dashboard/app-view/tabs/activity-feed/events/types.ts

@@ -1,87 +1,141 @@
 import { z } from "zod";
 
-export type PorterAppEventType = 'BUILD' | 'DEPLOY' | 'APP_EVENT' | 'PRE_DEPLOY';
+export type PorterAppEventType =
+  | "BUILD"
+  | "DEPLOY"
+  | "APP_EVENT"
+  | "PRE_DEPLOY";
 
 const porterAppAppEventMetadataValidator = z.object({
-    namespace: z.string(),
-    summary: z.string(),
-    short_summary: z.string(),
-    detail: z.string(),
-    service_name: z.string(),
-    app_revision_id: z.string(),
-    app_name: z.string(),
-    app_id: z.string(),
-    agent_event_id: z.number(),
+  namespace: z.string(),
+  summary: z.string(),
+  short_summary: z.string(),
+  detail: z.string(),
+  service_name: z.string(),
+  app_revision_id: z.string(),
+  app_name: z.string(),
+  app_id: z.string(),
+  agent_event_id: z.number(),
 });
 const porterAppDeployEventMetadataValidator = z.object({
-    image_tag: z.string(),
-    app_revision_id: z.string(),
-    service_deployment_metadata: z.record(z.object({
+  image_tag: z.string(),
+  app_revision_id: z.string(),
+  service_deployment_metadata: z
+    .record(
+      z.object({
         status: z.string(),
         type: z.string(),
-    })).optional(),
-    end_time: z.string().optional(),
+      })
+    )
+    .optional(),
+  end_time: z.string().optional(),
 });
 const porterAppBuildEventMetadataValidator = z.object({
-    repo: z.string().optional(),
-    action_run_id: z.number().optional(),
-    github_account_id: z.number().optional(),
-    end_time: z.string().optional(),
-    commit_sha: z.string().optional(),
-})
+  repo: z.string().optional(),
+  action_run_id: z.number().optional(),
+  github_account_id: z.number().optional(),
+  end_time: z.string().optional(),
+  commit_sha: z.string().optional(),
+});
 const porterAppPreDeployEventMetadataValidator = z.object({
-    start_time: z.string(),
-    end_time: z.string().optional(),
-    app_revision_id: z.string(),
-    commit_sha: z.string().optional(),
+  start_time: z.string(),
+  end_time: z.string().optional(),
+  app_revision_id: z.string(),
+  commit_sha: z.string().optional(),
 });
-export const porterAppEventValidator = z.discriminatedUnion("type", [
+export const porterAppNotificationEventMetadataValidator = z.object({
+  id: z.string(),
+  app_id: z.string(),
+  app_name: z.string(),
+  service_name: z.string(),
+  app_revision_id: z.string(),
+  error: z.object({
+    code: z.number(),
+    summary: z.string(),
+    detail: z.string(),
+    mitigation_steps: z.string(),
+    documentation: z.array(z.string()).default([]),
+  }),
+  timestamp: z.string(),
+  deployment: z.discriminatedUnion("status", [
     z.object({
-        id: z.string(),
-        created_at: z.string(),
-        updated_at: z.string(),
-        status: z.string().optional().default(""),
-        type: z.literal("BUILD"),
-        type_external_source: z.string().optional().default(""),
-        porter_app_id: z.number(),
-        metadata: porterAppBuildEventMetadataValidator
+      status: z.literal("PENDING"),
     }),
     z.object({
-        id: z.string(),
-        created_at: z.string(),
-        updated_at: z.string(),
-        status: z.string().optional().default(""),
-        type: z.literal("DEPLOY"),
-        type_external_source: z.string().optional().default(""),
-        porter_app_id: z.number(),
-        metadata: porterAppDeployEventMetadataValidator
+      status: z.literal("SUCCESS"),
     }),
     z.object({
-        id: z.string(),
-        created_at: z.string(),
-        updated_at: z.string(),
-        status: z.string().optional().default(""),
-        type: z.literal("PRE_DEPLOY"),
-        type_external_source: z.string().optional().default(""),
-        porter_app_id: z.number(),
-        metadata: porterAppPreDeployEventMetadataValidator
+      status: z.literal("FAILURE"),
     }),
     z.object({
-        id: z.string(),
-        created_at: z.string(),
-        updated_at: z.string(),
-        status: z.string().optional().default(""),
-        type: z.literal("APP_EVENT"),
-        type_external_source: z.string().optional().default(""),
-        porter_app_id: z.number(),
-        metadata: porterAppAppEventMetadataValidator
+      status: z.literal("UNKNOWN"),
     }),
+  ]),
+});
+export type PorterAppNotification = z.infer<
+  typeof porterAppNotificationEventMetadataValidator
+>;
+export const porterAppEventValidator = z.discriminatedUnion("type", [
+  z.object({
+    id: z.string(),
+    created_at: z.string(),
+    updated_at: z.string(),
+    status: z.string().optional().default(""),
+    type: z.literal("BUILD"),
+    type_external_source: z.string().optional().default(""),
+    porter_app_id: z.number(),
+    metadata: porterAppBuildEventMetadataValidator,
+  }),
+  z.object({
+    id: z.string(),
+    created_at: z.string(),
+    updated_at: z.string(),
+    status: z.string().optional().default(""),
+    type: z.literal("DEPLOY"),
+    type_external_source: z.string().optional().default(""),
+    porter_app_id: z.number(),
+    metadata: porterAppDeployEventMetadataValidator,
+  }),
+  z.object({
+    id: z.string(),
+    created_at: z.string(),
+    updated_at: z.string(),
+    status: z.string().optional().default(""),
+    type: z.literal("PRE_DEPLOY"),
+    type_external_source: z.string().optional().default(""),
+    porter_app_id: z.number(),
+    metadata: porterAppPreDeployEventMetadataValidator,
+  }),
+  z.object({
+    id: z.string(),
+    created_at: z.string(),
+    updated_at: z.string(),
+    status: z.string().optional().default(""),
+    type: z.literal("APP_EVENT"),
+    type_external_source: z.string().optional().default(""),
+    porter_app_id: z.number(),
+    metadata: porterAppAppEventMetadataValidator,
+  }),
+  z.object({
+    id: z.string(),
+    created_at: z.string(),
+    updated_at: z.string(),
+    type: z.literal("NOTIFICATION"),
+    porter_app_id: z.number(),
+    metadata: porterAppNotificationEventMetadataValidator,
+  }),
 ]);
 
-export const getPorterAppEventsValidator = z.array(porterAppEventValidator).optional().default([]);
+export const getPorterAppEventsValidator = z
+  .array(porterAppEventValidator)
+  .optional()
+  .default([]);
 
 export type PorterAppEvent = z.infer<typeof porterAppEventValidator>;
-export type PorterAppBuildEvent = PorterAppEvent & { type: 'BUILD' };
-export type PorterAppDeployEvent = PorterAppEvent & { type: 'DEPLOY' };
-export type PorterAppPreDeployEvent = PorterAppEvent & { type: 'PRE_DEPLOY' };
-export type PorterAppAppEvent = PorterAppEvent & { type: 'APP_EVENT' };
+export type PorterAppBuildEvent = PorterAppEvent & { type: "BUILD" };
+export type PorterAppDeployEvent = PorterAppEvent & { type: "DEPLOY" };
+export type PorterAppPreDeployEvent = PorterAppEvent & { type: "PRE_DEPLOY" };
+export type PorterAppAppEvent = PorterAppEvent & { type: "APP_EVENT" };
+export type PorterAppNotificationEvent = PorterAppEvent & {
+  type: "NOTIFICATION";
+};

+ 301 - 0
dashboard/src/main/home/app-dashboard/app-view/tabs/notifications/NotificationExpandedView.tsx

@@ -0,0 +1,301 @@
+import React, { useMemo } from "react";
+import styled from "styled-components";
+import { match } from "ts-pattern";
+
+import Button from "components/porter/Button";
+import Container from "components/porter/Container";
+import Link from "components/porter/Link";
+import Spacer from "components/porter/Spacer";
+import Tag from "components/porter/Tag";
+import Text from "components/porter/Text";
+import Logs from "main/home/app-dashboard/validate-apply/logs/Logs";
+import { useIntercom } from "lib/hooks/useIntercom";
+import { type ClientNotification } from "lib/porter-apps/notification";
+
+import { feedDate } from "shared/string_utils";
+import calendar from "assets/calendar-02.svg";
+import chat from "assets/chat.svg";
+import document from "assets/document.svg";
+import job from "assets/job.png";
+import time from "assets/time.svg";
+import web from "assets/web.png";
+import worker from "assets/worker.png";
+
+type Props = {
+  notification: ClientNotification;
+  projectId: number;
+  clusterId: number;
+  appName: string;
+  deploymentTargetId: string;
+  appId: number;
+};
+
+const NotificationExpandedView: React.FC<Props> = ({
+  notification,
+  projectId,
+  clusterId,
+  appName,
+  deploymentTargetId,
+  appId,
+}) => {
+  const { showIntercomWithMessage } = useIntercom();
+
+  const summary = useMemo(() => {
+    if (notification.isDeployRelated) {
+      return "failed to deploy";
+    } else {
+      return "is unhealthy";
+    }
+  }, [JSON.stringify(notification)]);
+
+  // this is necessary because the name for the pre-deploy job is called "pre-deploy" by the front-end but predeploy in k8s
+  // TODO: standardize the naming of the pre-deploy job
+  const serviceNames = useMemo(() => {
+    if (notification.service.config.type === "predeploy") {
+      return ["predeploy"];
+    } else {
+      return [notification.service.name.value];
+    }
+  }, [notification.service.name.value]);
+
+  return (
+    <StyledNotificationExpandedView>
+      <ExpandedViewContent>
+        <Container row spaced>
+          <Container row>
+            <ServiceNameTag>
+              {match(notification.service.config.type)
+                .with("web", () => <ServiceTypeIcon src={web} />)
+                .with("worker", () => <ServiceTypeIcon src={worker} />)
+                .with("job", () => <ServiceTypeIcon src={job} />)
+                .with("predeploy", () => <ServiceTypeIcon src={job} />)
+                .exhaustive()}
+              <Spacer inline x={0.5} />
+              {notification.service.name.value}
+            </ServiceNameTag>
+            <Spacer inline x={0.5} />
+            <Text size={16} color={"#FFBF00"}>
+              {summary}
+            </Text>
+          </Container>
+          {notification.service.config.type === "job" && (
+            <Container row>
+              <Tag>
+                <TagIcon
+                  src={calendar}
+                  style={{ marginTop: "3px", marginLeft: "5px" }}
+                />
+                <Link
+                  to={`/apps/${appName}/job-history?service=${notification.service.name.value}`}
+                >
+                  <Text size={16}>Job history</Text>
+                </Link>
+              </Tag>
+            </Container>
+          )}
+        </Container>
+        <Spacer y={0.5} />
+        <StyledActivityFeed>
+          {notification.messages.map((message, i) => {
+            return (
+              <NotificationWrapper
+                isLast={i === notification.messages.length - 1}
+                key={i}
+              >
+                <Message key={i}>
+                  <Container row spaced>
+                    <Container row>
+                      <img
+                        src={document}
+                        style={{ width: "15px", marginRight: "15px" }}
+                      />
+                      {message.error.summary}
+                    </Container>
+                    <Container row>
+                      <img
+                        src={time}
+                        style={{ width: "15px", marginRight: "15px" }}
+                      />
+                      <Text>{feedDate(message.timestamp)}</Text>
+                    </Container>
+                  </Container>
+                  <Spacer y={0.5} />
+                  <Text>Details:</Text>
+                  <Spacer y={0.25} />
+                  <Container row>
+                    <Text color="helper">{message.error.detail}</Text>
+                  </Container>
+                  <Spacer y={0.5} />
+                  <Text>Resolution steps:</Text>
+                  <Spacer y={0.25} />
+                  <Container row>
+                    <Text color="helper">{message.error.mitigation_steps}</Text>
+                  </Container>
+                  <Spacer y={0.25} />
+                  <Container row>
+                    <Text color="helper">Need help troubleshooting?</Text>
+                    <Spacer inline x={0.5} />
+                    <Button
+                      onClick={() => {
+                        showIntercomWithMessage({
+                          message: `I need help troubleshooting an issue with my application ${appName} in project ${projectId}.`,
+                          delaySeconds: 0,
+                        });
+                      }}
+                    >
+                      <img
+                        src={chat}
+                        style={{ width: "15px", marginRight: "10px" }}
+                      />
+                      Talk to support
+                    </Button>
+                  </Container>
+                  {message.error.documentation.length > 0 && (
+                    <>
+                      <Spacer y={0.5} />
+                      <Text>Relevant documentation:</Text>
+                      <Spacer y={0.25} />
+                      <ul
+                        style={{ paddingInlineStart: "12px", marginTop: "0px" }}
+                      >
+                        {message.error.documentation.map((doc, i) => {
+                          return (
+                            <li key={i}>
+                              <a href={doc} target="_blank" rel="noreferrer">
+                                {doc}
+                              </a>
+                            </li>
+                          );
+                        })}
+                      </ul>
+                    </>
+                  )}
+                </Message>
+              </NotificationWrapper>
+            );
+          })}
+        </StyledActivityFeed>
+        <Spacer y={1} />
+        {notification.service.config.type !== "job" &&
+          notification.service.config.type !== "predeploy" && (
+            <Logs
+              projectId={projectId}
+              clusterId={clusterId}
+              appName={appName}
+              serviceNames={serviceNames}
+              deploymentTargetId={deploymentTargetId}
+              appRevisionId={notification.appRevisionId}
+              logFilterNames={["service_name"]}
+              appId={appId}
+              selectedService={serviceNames[0]}
+              selectedRevisionId={notification.appRevisionId}
+              defaultScrollToBottomEnabled={false}
+            />
+          )}
+      </ExpandedViewContent>
+      {/* uncomment below once we implement recommended actions */}
+      {/* <ExpandedViewFooter>
+        <Button>Take recommended action</Button>
+      </ExpandedViewFooter> */}
+    </StyledNotificationExpandedView>
+  );
+};
+
+export default NotificationExpandedView;
+
+const StyledNotificationExpandedView = styled.div`
+  height: 100%;
+  display: flex;
+  justify-content: space-between;
+  flex-direction: column;
+  animation: fadeIn 0.3s 0s;
+  padding: 70px;
+  padding-top: 15px;
+  @keyframes fadeIn {
+    from {
+      opacity: 0;
+    }
+    to {
+      opacity: 1;
+    }
+  }
+`;
+
+const ExpandedViewContent = styled.div`
+  display: flex;
+  flex-direction: column;
+`;
+
+const Message = styled.div`
+  margin-left: 20px;
+  width: 100%;
+  padding: 20px;
+  background: ${({ theme }) => theme.fg};
+  border: 1px solid ${({ theme }) => theme.border};
+  border-radius: 5px;
+  line-height: 1.5em;
+  font-size: 13px;
+  display: flex;
+  flex-direction: column;
+  opacity: 0;
+  animation: slideIn 0.5s 0s;
+  animation-fill-mode: forwards;
+  user-select: text;
+  @keyframes slideIn {
+    from {
+      margin-left: -10px;
+      opacity: 0;
+      margin-right: 10px;
+    }
+    to {
+      margin-left: 0;
+      opacity: 1;
+      margin-right: 0;
+    }
+  }
+`;
+
+// const ExpandedViewFooter = styled.div`
+//   display: flex;
+//   justify-content: flex-end;
+// `;
+
+const ServiceNameTag = styled.div`
+  display: flex;
+  justify-content: center;
+  padding: 3px 5px;
+  border-radius: 5px;
+  background: #ffffff22;
+  user-select: text;
+  font-size: 16px;
+`;
+
+const ServiceTypeIcon = styled.img`
+  height: 16px;
+  margin-top: 2px;
+`;
+
+const NotificationWrapper = styled.div<{ isLast: boolean }>`
+  display: flex;
+  align-items: center;
+  position: relative;
+  margin-bottom: ${(props) => (props.isLast ? "" : "25px")};
+`;
+
+const StyledActivityFeed = styled.div`
+  width: 100%;
+  animation: fadeIn 0.3s 0s;
+  @keyframes fadeIn {
+    from {
+      opacity: 0;
+    }
+    to {
+      opacity: 1;
+    }
+  }
+`;
+
+const TagIcon = styled.img`
+  height: 16px;
+  margin-right: 3px;
+`;

+ 141 - 0
dashboard/src/main/home/app-dashboard/app-view/tabs/notifications/NotificationFeed.tsx

@@ -0,0 +1,141 @@
+import React, { useEffect, useState } from "react";
+import { useHistory, useLocation } from "react-router";
+import styled from "styled-components";
+
+import Container from "components/porter/Container";
+import Fieldset from "components/porter/Fieldset";
+import Link from "components/porter/Link";
+import Spacer from "components/porter/Spacer";
+import Text from "components/porter/Text";
+import { type ClientNotification } from "lib/porter-apps/notification";
+
+import NotificationExpandedView from "./NotificationExpandedView";
+import NotificationList from "./NotificationList";
+
+type Props = {
+  notifications: ClientNotification[];
+  projectId: number;
+  clusterId: number;
+  appName: string;
+  deploymentTargetId: string;
+  appId: number;
+};
+
+const NotificationFeed: React.FC<Props> = ({
+  notifications,
+  projectId,
+  clusterId,
+  appName,
+  deploymentTargetId,
+  appId,
+}) => {
+  const { search } = useLocation();
+  const history = useHistory();
+  const queryParams = new URLSearchParams(search);
+  const notificationId = queryParams.get("notification_id");
+
+  const [selectedNotification, setSelectedNotification] = useState<
+    ClientNotification | undefined
+  >(undefined);
+
+  useEffect(() => {
+    if (
+      notificationId &&
+      notifications.length &&
+      notifications.find((n) => n.id === notificationId)
+    ) {
+      setSelectedNotification(
+        notifications.find((n) => n.id === notificationId)
+      );
+    } else {
+      setSelectedNotification(undefined);
+    }
+  }, [notificationId, JSON.stringify(notifications)]);
+
+  if (notifications.length === 0) {
+    return (
+      <Fieldset>
+        <Text size={16}>This application currently has no notifications. </Text>
+        <Spacer height="15px" />
+        <Text color="helper">
+          You will receive notifications here if we need to alert you about any
+          issues with your services.
+        </Text>
+      </Fieldset>
+    );
+  }
+
+  return (
+    <StyledNotificationFeed>
+      {selectedNotification ? (
+        <Container>
+          <Link to={`/apps/${appName}/notifications`}>
+            <BackButton>
+              <i className="material-icons">keyboard_backspace</i>
+              Notifications
+            </BackButton>
+          </Link>
+          <Spacer y={0.25} />
+          <NotificationExpandedView
+            notification={selectedNotification}
+            projectId={projectId}
+            clusterId={clusterId}
+            appName={appName}
+            deploymentTargetId={deploymentTargetId}
+            appId={appId}
+          />
+        </Container>
+      ) : (
+        <NotificationList
+          notifications={notifications}
+          onNotificationClick={(notification: ClientNotification) => {
+            history.push(
+              `/apps/${appName}/notifications?notification_id=${notification.id}`
+            );
+          }}
+        />
+      )}
+    </StyledNotificationFeed>
+  );
+};
+
+export default NotificationFeed;
+
+const StyledNotificationFeed = styled.div`
+  display: flex;
+  margin-bottom: -50px;
+  width: 100%;
+  animation: fadeIn 0.3s 0s;
+  @keyframes fadeIn {
+    from {
+      opacity: 0;
+    }
+    to {
+      opacity: 1;
+    }
+  }
+`;
+
+const BackButton = styled.div`
+  display: flex;
+  align-items: center;
+  max-width: fit-content;
+  cursor: pointer;
+  font-size: 11px;
+  max-height: fit-content;
+  padding: 5px 13px;
+  border: 1px solid #ffffff55;
+  border-radius: 100px;
+  color: white;
+  background: #ffffff11;
+
+  :hover {
+    background: #ffffff22;
+  }
+
+  > i {
+    color: white;
+    font-size: 16px;
+    margin-right: 6px;
+  }
+`;

+ 64 - 0
dashboard/src/main/home/app-dashboard/app-view/tabs/notifications/NotificationList.tsx

@@ -0,0 +1,64 @@
+import React from "react";
+import styled from "styled-components";
+
+import { type ClientNotification } from "lib/porter-apps/notification";
+
+import NotificationTile from "./NotificationTile";
+
+type Props = {
+  notifications: ClientNotification[];
+  onNotificationClick: (notification: ClientNotification) => void;
+};
+
+const NotificationList: React.FC<Props> = ({
+  notifications,
+  onNotificationClick,
+}) => {
+  return (
+    <StyledNotificationList>
+      {notifications.map((notif) => (
+        <NotificationTile
+          key={notif.id}
+          notification={notif}
+          onClick={() => {
+            onNotificationClick(notif);
+          }}
+        />
+      ))}
+    </StyledNotificationList>
+  );
+};
+
+export default NotificationList;
+
+const StyledNotificationList = styled.div`
+  width: 100%;
+  display: flex;
+  flex-direction: column;
+  gap: 12px;
+  height: 100%;
+  overflow: auto;
+  ::-webkit-scrollbar {
+    width: 3px;
+    :horizontal {
+      height: 3px;
+    }
+  }
+
+  ::-webkit-scrollbar-corner {
+    width: 3px;
+    background: #ffffff11;
+    color: white;
+  }
+
+  ::-webkit-scrollbar-track {
+    width: 3px;
+    -webkit-box-shadow: inset 0 0 6px rgba(0, 0, 0, 0.3);
+    box-shadow: inset 0 0 6px rgba(0, 0, 0, 0.3);
+  }
+
+  ::-webkit-scrollbar-thumb {
+    background-color: darkgrey;
+    outline: 1px solid slategrey;
+  }
+`;

+ 133 - 0
dashboard/src/main/home/app-dashboard/app-view/tabs/notifications/NotificationTile.tsx

@@ -0,0 +1,133 @@
+import React, { useMemo } from "react";
+import styled from "styled-components";
+import { match } from "ts-pattern";
+
+import Container from "components/porter/Container";
+import Spacer from "components/porter/Spacer";
+import Text from "components/porter/Text";
+import { type ClientNotification } from "lib/porter-apps/notification";
+
+import { feedDate } from "shared/string_utils";
+import job from "assets/job.png";
+import web from "assets/web.png";
+import worker from "assets/worker.png";
+
+type Props = {
+  notification: ClientNotification;
+  onClick: () => void;
+};
+
+const NotificationTile: React.FC<Props> = ({ notification, onClick }) => {
+  const summary = useMemo(() => {
+    if (notification.isDeployRelated) {
+      return "Your service failed to deploy";
+    } else {
+      return "Your service is unhealthy";
+    }
+  }, [JSON.stringify(notification)]);
+
+  return (
+    <StyledNotificationTile onClick={onClick}>
+      <Container row>
+        <Container row style={{ width: "200px" }}>
+          <NotificationSummary>{summary}</NotificationSummary>
+        </Container>
+        <Spacer inline x={0.5} />
+        <Container row style={{ width: "120px" }}>
+          <Text color="helper">{feedDate(notification.timestamp)}</Text>
+        </Container>
+        <Spacer inline x={0.5} />
+        <Container row style={{ width: "200px" }}>
+          <ServiceNameTag>
+            {match(notification.service.config.type)
+              .with("web", () => <ServiceTypeIcon src={web} />)
+              .with("worker", () => <ServiceTypeIcon src={worker} />)
+              .with("job", () => <ServiceTypeIcon src={job} />)
+              .with("predeploy", () => <ServiceTypeIcon src={job} />)
+              .exhaustive()}
+            <Spacer inline x={0.5} />
+            {notification.service.name.value}
+          </ServiceNameTag>
+        </Container>
+      </Container>
+      <Container row style={{ paddingRight: "10px" }}>
+        <StatusDot color={"#FFBF00"} />
+      </Container>
+    </StyledNotificationTile>
+  );
+};
+
+export default NotificationTile;
+
+const StyledNotificationTile = styled.div`
+  user-select: none;
+  padding: 15px 10px;
+  cursor: pointer;
+  border-radius: 5px;
+  background: ${(props) => props.theme.clickable.bg};
+  border: 1px solid #494b4f;
+  :hover {
+    border: 1px solid #7a7b80;
+  }
+  animation: fadeIn 0.3s 0s;
+  @keyframes fadeIn {
+    from {
+      opacity: 0;
+    }
+    to {
+      opacity: 1;
+    }
+  }
+  display: flex;
+  justify-content: space-between;
+`;
+
+const NotificationSummary = styled.div`
+  color: #ffbf00;
+  font-size: 13px;
+  font-weight: 500;
+`;
+
+const ServiceNameTag = styled.div`
+  display: flex;
+  justify-content: center;
+  padding: 3px 5px;
+  border-radius: 5px;
+  background: #ffffff22;
+  user-select: text;
+  font-size: 13px;
+`;
+
+const ServiceTypeIcon = styled.img`
+  height: 12px;
+  margin-top: 2px;
+`;
+
+const StatusDot = styled.div<{ color: string }>`
+  min-width: 7px;
+  max-width: 7px;
+  height: 7px;
+  margin-left: 10px;
+  border-radius: 50%;
+  background: ${({ color }) => color};
+
+  box-shadow: 0 0 0 0 rgba(0, 0, 0, 1);
+  transform: scale(1);
+  animation: pulse 2s infinite;
+  @keyframes pulse {
+    0% {
+      transform: scale(0.95);
+      box-shadow: 0 0 0 0 rgba(0, 0, 0, 0.9);
+    }
+
+    70% {
+      transform: scale(1);
+      box-shadow: 0 0 0 10px rgba(0, 0, 0, 0);
+    }
+
+    100% {
+      transform: scale(0.95);
+      box-shadow: 0 0 0 0 rgba(0, 0, 0, 0);
+    }
+  }
+`;

+ 0 - 1
dashboard/src/main/home/app-dashboard/validate-apply/build-settings/docker/DockerfileSettings.tsx

@@ -6,7 +6,6 @@ import { PorterAppFormData } from 'lib/porter-apps';
 import Input from 'components/porter/Input';
 import FileSelector from '../FileSelector';
 import styled from 'styled-components';
-import CollapsibleContainer from 'components/porter/CollapsibleContainer';
 import Button from 'components/porter/Button';
 
 type Props = {

A diferenza do arquivo foi suprimida porque é demasiado grande
+ 561 - 461
dashboard/src/main/home/app-dashboard/validate-apply/logs/Logs.tsx


+ 2 - 6
dashboard/src/main/home/app-dashboard/validate-apply/metrics/MetricsSection.tsx

@@ -56,13 +56,9 @@ const MetricsSection: React.FunctionComponent<PropsType> = ({
   const [showAutoscalingThresholds, setShowAutoscalingThresholds] =
     useState(true);
 
-  // filter out jobs until we can display metrics on them
   const serviceOptions: GenericFilterOption[] = useMemo(() => {
-    const nonJobServiceNames = services
-      .filter((s) => s.config.type !== "job")
-      .map((s) => s.name);
-    return nonJobServiceNames.map(({ value }) =>
-      GenericFilterOption.of(value, value)
+    return services.map((svc) =>
+      GenericFilterOption.of(svc.name.value, svc.name.value)
     );
   }, [services]);
 

+ 1 - 0
dashboard/src/shared/themes/midnight.ts

@@ -6,6 +6,7 @@ const theme = {
   button: "#3A48CA",
   clickable: {
     bg: "linear-gradient(180deg, #171B21, #121212)",
+    clickedBg: "#202126",
   },
   modalBg: "#171B2111",
   text: {

+ 15 - 0
internal/kubernetes/agent.go

@@ -994,6 +994,21 @@ func (a *Agent) GetDeployment(c grapher.Object) (*appsv1.Deployment, error) {
 	return res, nil
 }
 
+// GetDeploymentsBySelector returns the deployments by label selector
+func (a *Agent) GetDeploymentsBySelector(ctx context.Context, namespace string, selector string) (*appsv1.DeploymentList, error) {
+	res, err := a.Clientset.AppsV1().Deployments(namespace).List(
+		ctx,
+		metav1.ListOptions{
+			LabelSelector: selector,
+		},
+	)
+	if err != nil {
+		return nil, err
+	}
+
+	return res, nil
+}
+
 // GetStatefulSet gets the statefulset given the name and namespace
 func (a *Agent) GetStatefulSet(c grapher.Object) (*appsv1.StatefulSet, error) {
 	res, err := a.Clientset.AppsV1().StatefulSets(c.Namespace).Get(

+ 356 - 0
internal/porter_app/notifications/app_event.go

@@ -0,0 +1,356 @@
+package notifications
+
+import (
+	"context"
+	"encoding/json"
+	"strconv"
+	"time"
+
+	"github.com/google/uuid"
+	"github.com/porter-dev/porter/internal/models"
+	"github.com/porter-dev/porter/internal/repository"
+	"github.com/porter-dev/porter/internal/telemetry"
+)
+
+// PorterAppEventType_Notification is the type of a Porter App Event that is a notification
+const PorterAppEventType_Notification = "NOTIFICATION"
+
+// PorterAppEventType_Deploy is the type of a Porter App Event that is a deploy event
+const PorterAppEventType_Deploy = "DEPLOY"
+
+// PorterAppEventStatus is an alias for a string that represents a Porter App Event Status
+type PorterAppEventStatus string
+
+const (
+	// PorterAppEventStatus_Success represents a Porter App Event that was successful
+	PorterAppEventStatus_Success PorterAppEventStatus = "SUCCESS"
+	// PorterAppEventStatus_Failed represents a Porter App Event that failed
+	PorterAppEventStatus_Failed PorterAppEventStatus = "FAILED"
+	// PorterAppEventStatus_Progressing represents a Porter App Event that is in progress
+	PorterAppEventStatus_Progressing PorterAppEventStatus = "PROGRESSING"
+	// PorterAppEventStatus_Canceled represents a Porter App Event that has been canceled
+	PorterAppEventStatus_Canceled PorterAppEventStatus = "CANCELED"
+)
+
+// AppEventMetadata is the metadata for an app event
+type AppEventMetadata struct {
+	// AgentEventID is the ID of the porter agent event that triggered this app event
+	AgentEventID int `json:"agent_event_id"`
+	// Revision is the revision number of the app when this event was fired
+	Revision int `json:"revision"`
+	// AppRevisionID is the revision ID of the app when this event was fired
+	AppRevisionID string `json:"app_revision_id"`
+	// ServiceName refers to the name of the service this event refers to
+	ServiceName string `json:"service_name"`
+	// ServiceType refers to the type of the service this event refers to
+	ServiceType string `json:"service_type"`
+	// ShortSummary is the short summary of the app event
+	ShortSummary string `json:"short_summary"`
+	// Summary is the summary of the app event
+	Summary string `json:"summary"`
+	// AppID is the ID of the app that this event refers to
+	AppID string `json:"app_id"`
+	// AppName is the name of the app that this event refers to
+	AppName string `json:"app_name"`
+	// Detail is the detail of the app event
+	Detail string `json:"detail"`
+}
+
+// ServiceDeploymentMetadata contains information about a service when it deploys, stored in the deploy event
+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"`
+}
+
+// parseAgentEventMetadata parses raw app event metadata to a AppEventMetadata struct
+func parseAgentEventMetadata(metadata map[string]interface{}) (*AppEventMetadata, error) {
+	appEventMetadata := &AppEventMetadata{}
+
+	bytes, err := json.Marshal(metadata)
+	if err != nil {
+		return nil, err
+	}
+	err = json.Unmarshal(bytes, appEventMetadata)
+	if err != nil {
+		return nil, err
+	}
+
+	return appEventMetadata, nil
+}
+
+// isNotificationDuplicate checks if another app event exists in the db with the same agent event id
+func isNotificationDuplicate(
+	ctx context.Context,
+	notification Notification,
+	eventRepo repository.PorterAppEventRepository,
+	deploymentTargetID string,
+) (bool, error) {
+	ctx, span := telemetry.NewSpan(ctx, "is-notification-duplicate")
+	defer span.End()
+
+	deploymentTargetUUID, err := uuid.Parse(deploymentTargetID)
+	if err != nil {
+		return false, telemetry.Error(ctx, span, err, "error parsing deployment target id")
+	}
+	if deploymentTargetUUID == uuid.Nil {
+		return false, telemetry.Error(ctx, span, nil, "deployment target id cannot be nil")
+	}
+
+	appIdInt, err := strconv.Atoi(notification.AppID)
+	if err != nil {
+		return false, telemetry.Error(ctx, span, err, "error converting app id to int")
+	}
+
+	telemetry.WithAttributes(span,
+		telemetry.AttributeKV{Key: "app-id", Value: notification.AppID},
+		telemetry.AttributeKV{Key: "app-name", Value: notification.AppName},
+		telemetry.AttributeKV{Key: "app-revision-id", Value: notification.AppRevisionID},
+		telemetry.AttributeKV{Key: "agent-event-id", Value: notification.AgentEventID},
+		telemetry.AttributeKV{Key: "service-name", Value: notification.ServiceName},
+	)
+
+	existingEvents, _, err := eventRepo.ListEventsByPorterAppIDAndDeploymentTargetID(ctx, uint(appIdInt), deploymentTargetUUID)
+	if err != nil {
+		return false, telemetry.Error(ctx, span, err, "error listing porter app events for event type with deployment target id")
+	}
+
+	for _, existingEvent := range existingEvents {
+		if existingEvent != nil && existingEvent.Type == PorterAppEventType_Notification {
+			existingNotification, err := NotificationFromPorterAppEvent(existingEvent)
+			if err != nil {
+				continue
+			}
+			if existingNotification.AgentEventID == 0 {
+				continue
+			}
+			if existingNotification.AgentEventID == notification.AgentEventID {
+				return true, nil
+			}
+		}
+	}
+
+	return false, nil
+}
+
+// updateDeployEventInput is the input to updateDeployEvent
+type updateDeployEventInput struct {
+	Notification
+	EventRepo repository.PorterAppEventRepository
+	Status    PorterAppEventStatus
+}
+
+// updateDeployEvent updates the service status of a deploy event and possibly the event status itself with the input status
+// TODO: simplify this logic after https://linear.app/porter/issue/POR-2101/turn-servicedeploymentmetadata-from-a-map-into-a-list-in-ccp
+func updateDeployEvent(ctx context.Context, inp updateDeployEventInput) error {
+	ctx, span := telemetry.NewSpan(ctx, "update-matching-deploy-event")
+	defer span.End()
+
+	telemetry.WithAttributes(span, telemetry.AttributeKV{Key: "matching-k8s-deployment-status", Value: inp.Deployment.Status})
+
+	appID, err := strconv.Atoi(inp.Notification.AppID)
+	if err != nil {
+		return telemetry.Error(ctx, span, err, "error converting app id to int")
+	}
+
+	matchEvent, err := inp.EventRepo.ReadDeployEventByAppRevisionID(ctx, uint(appID), inp.Notification.AppRevisionID)
+	if err != nil {
+		return telemetry.Error(ctx, span, err, "error finding matching deploy event")
+	}
+	if matchEvent.ID == uuid.Nil {
+		return telemetry.Error(ctx, span, nil, "no matching deploy event found")
+	}
+	if matchEvent.Status != string(PorterAppEventStatus_Progressing) {
+		return nil // nothing to update here
+	}
+
+	serviceStatus, ok := matchEvent.Metadata["service_deployment_metadata"]
+	if !ok {
+		return telemetry.Error(ctx, span, nil, "service deployment metadata not found in deploy event metadata")
+	}
+	serviceDeploymentGenericMap, ok := serviceStatus.(map[string]interface{})
+	if !ok {
+		return telemetry.Error(ctx, span, nil, "service deployment metadata is not correct type")
+	}
+	serviceDeploymentMap := make(map[string]ServiceDeploymentMetadata)
+	for k, v := range serviceDeploymentGenericMap {
+		by, err := json.Marshal(v)
+		if err != nil {
+			return telemetry.Error(ctx, span, nil, "unable to marshal service deployment metadata")
+		}
+
+		var serviceDeploymentMetadata ServiceDeploymentMetadata
+		err = json.Unmarshal(by, &serviceDeploymentMetadata)
+		if err != nil {
+			return telemetry.Error(ctx, span, nil, "unable to unmarshal service deployment metadata")
+		}
+		serviceDeploymentMap[k] = serviceDeploymentMetadata
+	}
+	serviceDeploymentMetadata, ok := serviceDeploymentMap[inp.Notification.ServiceName]
+	if !ok {
+		return telemetry.Error(ctx, span, nil, "deployment metadata not found for service")
+	}
+
+	telemetry.WithAttributes(span, telemetry.AttributeKV{Key: "existing-status", Value: string(serviceDeploymentMetadata.Status)})
+
+	if serviceDeploymentMetadata.Status != PorterAppEventStatus_Progressing {
+		return nil // nothing to update here
+	}
+	// update the map with the new status
+	serviceDeploymentMetadata.Status = inp.Status
+	serviceDeploymentMap[inp.Notification.ServiceName] = serviceDeploymentMetadata
+
+	// update the deploy event with new map and status if all services are done
+	matchEvent.Metadata["service_deployment_metadata"] = serviceDeploymentMap
+	allServicesDone := true
+	anyServicesFailed := false
+	for _, deploymentMetadata := range serviceDeploymentMap {
+		if deploymentMetadata.Status == PorterAppEventStatus_Progressing {
+			allServicesDone = false
+			break
+		}
+		if deploymentMetadata.Status == PorterAppEventStatus_Failed {
+			anyServicesFailed = true
+		}
+	}
+	if allServicesDone {
+		matchEvent.Metadata["end_time"] = time.Now().UTC()
+		if anyServicesFailed {
+			matchEvent.Status = string(PorterAppEventStatus_Failed)
+		} else {
+			matchEvent.Status = string(PorterAppEventStatus_Success)
+		}
+	}
+
+	err = inp.EventRepo.UpdateEvent(ctx, &matchEvent)
+	if err != nil {
+		return telemetry.Error(ctx, span, err, "error updating deploy event")
+	}
+
+	return nil
+}
+
+// serviceDeploymentMetadataFromDeployEvent returns the serviceDeploymentMetadata of a service from a deploy event
+// TODO: simplify this logic after https://linear.app/porter/issue/POR-2101/turn-servicedeploymentmetadata-from-a-map-into-a-list-in-ccp
+func serviceDeploymentMetadataFromDeployEvent(ctx context.Context, deployEvent models.PorterAppEvent, serviceName string) (ServiceDeploymentMetadata, error) {
+	ctx, span := telemetry.NewSpan(ctx, "service-deployment-metadata-from-deploy-event")
+	defer span.End()
+
+	serviceDeploymentMetadata := ServiceDeploymentMetadata{}
+
+	if deployEvent.ID == uuid.Nil {
+		return serviceDeploymentMetadata, telemetry.Error(ctx, span, nil, "deploy event id cannot be nil")
+	}
+
+	telemetry.WithAttributes(span,
+		telemetry.AttributeKV{Key: "app-id", Value: deployEvent.PorterAppID},
+		telemetry.AttributeKV{Key: "event-id", Value: deployEvent.ID},
+		telemetry.AttributeKV{Key: "event-type", Value: deployEvent.Type},
+		telemetry.AttributeKV{Key: "event-status", Value: deployEvent.Status},
+		telemetry.AttributeKV{Key: "service-name", Value: serviceName},
+	)
+
+	if deployEvent.Type != string(PorterAppEventType_Deploy) {
+		return serviceDeploymentMetadata, telemetry.Error(ctx, span, nil, "event is not a deploy event")
+	}
+
+	serviceStatus, ok := deployEvent.Metadata["service_deployment_metadata"]
+	if !ok {
+		return serviceDeploymentMetadata, telemetry.Error(ctx, span, nil, "service deployment metadata not found in deploy event metadata")
+	}
+	serviceDeploymentGenericMap, ok := serviceStatus.(map[string]interface{})
+	if !ok {
+		return serviceDeploymentMetadata, telemetry.Error(ctx, span, nil, "service deployment metadata is not correct type")
+	}
+	serviceDeploymentMap := make(map[string]ServiceDeploymentMetadata)
+	for k, v := range serviceDeploymentGenericMap {
+		by, err := json.Marshal(v)
+		if err != nil {
+			return serviceDeploymentMetadata, telemetry.Error(ctx, span, nil, "unable to marshal service deployment metadata")
+		}
+
+		var serviceDeploymentMetadata ServiceDeploymentMetadata
+		err = json.Unmarshal(by, &serviceDeploymentMetadata)
+		if err != nil {
+			return serviceDeploymentMetadata, telemetry.Error(ctx, span, nil, "unable to unmarshal service deployment metadata")
+		}
+		serviceDeploymentMap[k] = serviceDeploymentMetadata
+	}
+	serviceDeploymentMetadata, ok = serviceDeploymentMap[serviceName]
+	if !ok {
+		return serviceDeploymentMetadata, telemetry.Error(ctx, span, nil, "deployment metadata not found for service")
+	}
+	telemetry.WithAttributes(span, telemetry.AttributeKV{Key: "status", Value: string(serviceDeploymentMetadata.Status)})
+
+	return serviceDeploymentMetadata, nil
+}
+
+// saveNotification saves a notification to the db
+// TODO: save the notification in its own table rather than co-opting the porter app events table
+func saveNotification(ctx context.Context, notification Notification, eventRepo repository.PorterAppEventRepository, deploymentTargetID string) error {
+	ctx, span := telemetry.NewSpan(ctx, "save-notification")
+	defer span.End()
+
+	telemetry.WithAttributes(span,
+		telemetry.AttributeKV{Key: "app-id", Value: notification.AppID},
+		telemetry.AttributeKV{Key: "app-name", Value: notification.AppName},
+		telemetry.AttributeKV{Key: "app-revision-id", Value: notification.AppRevisionID},
+		telemetry.AttributeKV{Key: "agent-event-id", Value: notification.AgentEventID},
+		telemetry.AttributeKV{Key: "service-name", Value: notification.ServiceName},
+		telemetry.AttributeKV{Key: "deployment-target-id", Value: deploymentTargetID},
+	)
+
+	appID, err := strconv.Atoi(notification.AppID)
+	if err != nil {
+		return telemetry.Error(ctx, span, err, "error converting app id to int")
+	}
+
+	deploymentTargetUUID, err := uuid.Parse(deploymentTargetID)
+	if err != nil {
+		return telemetry.Error(ctx, span, err, "error parsing deployment target id")
+	}
+	if deploymentTargetUUID == uuid.Nil {
+		return telemetry.Error(ctx, span, err, "deployment target id cannot be nil")
+	}
+
+	notificationMap := make(map[string]any)
+	bytes, err := json.Marshal(notification)
+	if err != nil {
+		return telemetry.Error(ctx, span, err, "error marshaling notification")
+	}
+	err = json.Unmarshal(bytes, &notificationMap)
+	if err != nil {
+		return telemetry.Error(ctx, span, err, "error unmarshaling notification")
+	}
+
+	err = eventRepo.CreateEvent(ctx, &models.PorterAppEvent{
+		ID:                 uuid.New(),
+		Type:               string(PorterAppEventType_Notification),
+		PorterAppID:        uint(appID),
+		DeploymentTargetID: deploymentTargetUUID,
+		Metadata:           notificationMap,
+	})
+	if err != nil {
+		return telemetry.Error(ctx, span, err, "error creating porter app event")
+	}
+
+	return nil
+}
+
+// NotificationFromPorterAppEvent converts a PorterAppEvent to a Notification
+func NotificationFromPorterAppEvent(appEvent *models.PorterAppEvent) (*Notification, error) {
+	notification := &Notification{}
+	bytes, err := json.Marshal(appEvent.Metadata)
+	if err != nil {
+		return notification, err
+	}
+	err = json.Unmarshal(bytes, notification)
+	if err != nil {
+		return notification, err
+	}
+
+	return notification, nil
+}

+ 220 - 0
internal/porter_app/notifications/deployment.go

@@ -0,0 +1,220 @@
+package notifications
+
+import (
+	"context"
+	"fmt"
+	"strconv"
+	"strings"
+
+	"github.com/porter-dev/porter/internal/kubernetes"
+	"github.com/porter-dev/porter/internal/porter_app/notifications/porter_error"
+	"github.com/porter-dev/porter/internal/repository"
+	"github.com/porter-dev/porter/internal/telemetry"
+	v1 "k8s.io/api/apps/v1"
+)
+
+// Deployment represents metadata about a k8s deployment
+type Deployment struct {
+	Status DeploymentStatus `json:"status"`
+}
+
+// DeploymentStatus represents the status of a k8s deployment
+type DeploymentStatus string
+
+const (
+	// DeploymentStatus_Unknown indicates that the status of the deployment is unknown because we have not queried for it yet
+	DeploymentStatus_Unknown DeploymentStatus = "UNKNOWN"
+	// DeploymentStatus_Pending indicates that the deployment is still in progress
+	DeploymentStatus_Pending DeploymentStatus = "PENDING"
+	// DeploymentStatus_Success indicates that the deployment was successful
+	DeploymentStatus_Success DeploymentStatus = "SUCCESS"
+	// DeploymentStatus_Failure indicates that the deployment failed
+	DeploymentStatus_Failure DeploymentStatus = "FAILURE"
+)
+
+// hydrateNotificationWithDeploymentInput is the input struct for hydrateNotificationWithDeployment
+type hydrateNotificationWithDeploymentInput struct {
+	// Notification is the notification to hydrate
+	Notification
+	// DeploymentTargetId is the ID of the deployment target
+	DeploymentTargetId string
+	// Namespace is the namespace of the deployment target
+	Namespace string
+	// K8sAgent is the k8s agent, used to query for deployment info
+	K8sAgent kubernetes.Agent
+	// EventRepo is the repository for app events, used to check if we've already marked this deployment as successful/failed
+	EventRepo repository.PorterAppEventRepository
+}
+
+// hydrateNotificationWithDeployment hydrates a notification with k8s deployment info
+func hydrateNotificationWithDeployment(ctx context.Context, inp hydrateNotificationWithDeploymentInput) (Notification, error) {
+	ctx, span := telemetry.NewSpan(ctx, "hydrate-notification-with-deployment")
+	defer span.End()
+
+	hydratedNotification := inp.Notification
+
+	if inp.Notification.Deployment.Status != DeploymentStatus_Unknown {
+		return hydratedNotification, nil
+	}
+
+	telemetry.WithAttributes(span,
+		telemetry.AttributeKV{Key: "deployment-target-id", Value: inp.DeploymentTargetId},
+		telemetry.AttributeKV{Key: "namespace", Value: inp.Namespace},
+		telemetry.AttributeKV{Key: "app-name", Value: inp.AppName},
+		telemetry.AttributeKV{Key: "app-revision-id", Value: inp.Notification.AppRevisionID},
+		telemetry.AttributeKV{Key: "service-name", Value: inp.ServiceName},
+	)
+
+	// first, we check if we've already marked this deployment as successful or failed
+	status, err := porterAppDeployEventStatus(ctx, porterAppDeployEventStatusInput{
+		AppID:         inp.AppID,
+		EventRepo:     inp.EventRepo,
+		AppRevisionID: inp.Notification.AppRevisionID,
+		ServiceName:   inp.Notification.ServiceName,
+	})
+	if err != nil {
+		err := telemetry.Error(ctx, span, err, "failed to get deployment status from db")
+		return hydratedNotification, err
+	}
+
+	// the status is still pending in the db, so we haven't updated the user on it yet
+	// therefore, we check the k8s deployment status
+	if status == DeploymentStatus_Pending {
+		selectors := []string{
+			fmt.Sprintf("porter.run/deployment-target-id=%s", inp.DeploymentTargetId),
+			fmt.Sprintf("porter.run/app-name=%s", inp.AppName),
+			fmt.Sprintf("porter.run/app-revision-id=%s", inp.Notification.AppRevisionID),
+			fmt.Sprintf("porter.run/service-name=%s", inp.ServiceName),
+		}
+		depls, err := inp.K8sAgent.GetDeploymentsBySelector(ctx, inp.Namespace, strings.Join(selectors, ","))
+		if err != nil {
+			err := telemetry.Error(ctx, span, err, "failed to get deployments for notification")
+			return hydratedNotification, err
+		}
+		if len(depls.Items) == 0 {
+			err := telemetry.Error(ctx, span, nil, "no deployments found for notification")
+			return hydratedNotification, err
+		}
+		if len(depls.Items) > 1 {
+			err := telemetry.Error(ctx, span, nil, "multiple deployments found for notification")
+			return hydratedNotification, err
+		}
+
+		matchingDeployment := depls.Items[0]
+		telemetry.WithAttributes(span,
+			telemetry.AttributeKV{Key: "deployment-name", Value: matchingDeployment.Name},
+			telemetry.AttributeKV{Key: "deployment-uid", Value: matchingDeployment.ObjectMeta.UID},
+			telemetry.AttributeKV{Key: "deployment-creation-timestamp", Value: matchingDeployment.ObjectMeta.CreationTimestamp},
+		)
+		status = k8sDeploymentStatus(matchingDeployment)
+	}
+
+	telemetry.WithAttributes(span, telemetry.AttributeKV{Key: "deployment-status", Value: status})
+	if status == DeploymentStatus_Unknown {
+		err := telemetry.Error(ctx, span, nil, "unable to determine status of deployment")
+		return hydratedNotification, err
+	}
+
+	hydratedNotification.Deployment = Deployment{
+		Status: status,
+	}
+
+	return hydratedNotification, nil
+}
+
+// porterAppDeployEventStatusInput is the input struct for porterAppDeployEventStatus
+type porterAppDeployEventStatusInput struct {
+	// AppID is the ID of the app
+	AppID string
+	// EventRepo is the repository for app events, used to check if we've already marked this deployment as successful/failed
+	EventRepo repository.PorterAppEventRepository
+	// AppRevisionID is the ID of the app revision
+	AppRevisionID string
+	// ServiceName is the name of the service
+	ServiceName string
+}
+
+// porterAppDeployEventStatus returns the status of a deploy event from the app events repository
+func porterAppDeployEventStatus(ctx context.Context, inp porterAppDeployEventStatusInput) (DeploymentStatus, error) {
+	ctx, span := telemetry.NewSpan(ctx, "db-deploy-event-status")
+	defer span.End()
+
+	deploymentStatus := DeploymentStatus_Unknown
+
+	appIdInt, err := strconv.Atoi(inp.AppID)
+	if err != nil {
+		return deploymentStatus, telemetry.Error(ctx, span, err, "failed to convert app id to int")
+	}
+	matchingDeployEvent, err := inp.EventRepo.ReadDeployEventByAppRevisionID(ctx, uint(appIdInt), inp.AppRevisionID)
+	if err != nil {
+		return deploymentStatus, telemetry.Error(ctx, span, err, "failed to read deploy event by app revision id")
+	}
+
+	serviceDeploymentMetadata, err := serviceDeploymentMetadataFromDeployEvent(ctx, matchingDeployEvent, inp.ServiceName)
+	if err != nil {
+		return deploymentStatus, telemetry.Error(ctx, span, err, "failed to get service deployment metadata from deploy event")
+	}
+
+	switch serviceDeploymentMetadata.Status {
+	case PorterAppEventStatus_Success:
+		deploymentStatus = DeploymentStatus_Success
+	case PorterAppEventStatus_Failed:
+		deploymentStatus = DeploymentStatus_Failure
+	case PorterAppEventStatus_Progressing:
+		deploymentStatus = DeploymentStatus_Pending
+	default:
+		deploymentStatus = DeploymentStatus_Unknown
+	}
+
+	telemetry.WithAttributes(span, telemetry.AttributeKV{Key: "deployment-status", Value: string(deploymentStatus)})
+
+	return deploymentStatus, nil
+}
+
+// k8sDeploymentStatus returns the status of a k8s deployment
+func k8sDeploymentStatus(depl v1.Deployment) DeploymentStatus {
+	deploymentStatus := DeploymentStatus_Unknown
+
+	if depl.Status.Replicas == depl.Status.ReadyReplicas &&
+		depl.Status.Replicas == depl.Status.AvailableReplicas &&
+		depl.Status.Replicas == depl.Status.UpdatedReplicas {
+		deploymentStatus = DeploymentStatus_Success
+	} else {
+		for _, condition := range depl.Status.Conditions {
+			if condition.Type == "Progressing" {
+				if condition.Status == "False" && condition.Reason == "ProgressDeadlineExceeded" {
+					deploymentStatus = DeploymentStatus_Failure
+					break
+				} else {
+					deploymentStatus = DeploymentStatus_Pending
+				}
+			}
+		}
+	}
+
+	return deploymentStatus
+}
+
+var fatalDeploymentErrorCodes = []porter_error.PorterErrorCode{
+	porter_error.PorterErrorCode_NonZeroExitCode,
+	porter_error.PorterErrorCode_NonZeroExitCode_InvalidStartCommand,
+	porter_error.PorterErrorCode_NonZeroExitCode_CommonIssues,
+	porter_error.PorterErrorCode_ReadinessHealthCheck,
+	porter_error.PorterErrorCode_LivenessHealthCheck,
+	porter_error.PorterErrorCode_InvalidImageError,
+	porter_error.PorterErrorCode_RestartedDueToError,
+	porter_error.PorterErrorCode_MemoryLimitExceeded_ScaleUp,
+	porter_error.PorterErrorCode_CPULimitExceeded_ScaleUp,
+	porter_error.PorterErrorCode_CannotBeScheduled,
+}
+
+// errorCodeIndicatesDeploymentFailure returns true if the error code indicates that the deployment will eventually time out and fail
+// we use this to report deployment failure to the user early, rather than waiting for the deployment to time out
+func errorCodeIndicatesDeploymentFailure(errorCode porter_error.PorterErrorCode) bool {
+	for _, fatalErrorCode := range fatalDeploymentErrorCodes {
+		if errorCode == fatalErrorCode {
+			return true
+		}
+	}
+	return false
+}

+ 155 - 0
internal/porter_app/notifications/notification.go

@@ -0,0 +1,155 @@
+package notifications
+
+import (
+	"context"
+	"strings"
+	"time"
+
+	"github.com/google/uuid"
+	"github.com/porter-dev/porter/internal/kubernetes"
+	"github.com/porter-dev/porter/internal/porter_app/notifications/porter_error"
+	"github.com/porter-dev/porter/internal/repository"
+	"github.com/porter-dev/porter/internal/telemetry"
+)
+
+// HandleNotificationInput is the input to HandleNotification
+type HandleNotificationInput struct {
+	// RawAgentEventMetadata is the raw metadata from the agent event
+	RawAgentEventMetadata map[string]any
+	// EventRepo is the repository for app events
+	EventRepo repository.PorterAppEventRepository
+	// DeploymentTargetID is the ID of the deployment target
+	DeploymentTargetID string
+	// Namespace is the namespace of the deployment target
+	Namespace string
+	// K8sAgent is the k8s agent, used to query for deployment info
+	K8sAgent kubernetes.Agent
+}
+
+// HandleNotification handles the logic for processing agent events
+func HandleNotification(ctx context.Context, inp HandleNotificationInput) error {
+	ctx, span := telemetry.NewSpan(ctx, "internal-handle-notification")
+	defer span.End()
+
+	// 1. parse agent event
+	agentEventMetadata, err := parseAgentEventMetadata(inp.RawAgentEventMetadata)
+	if err != nil {
+		return telemetry.Error(ctx, span, err, "failed to unmarshal app event metadata")
+	}
+	if agentEventMetadata == nil {
+		return telemetry.Error(ctx, span, nil, "app event metadata is nil")
+	}
+
+	// 2. convert agent event to notification
+	hydratedNotification := agentEventToNotification(*agentEventMetadata)
+
+	// 3. dedupe notification
+	isDuplicate, err := isNotificationDuplicate(ctx, hydratedNotification, inp.EventRepo, inp.DeploymentTargetID)
+	if err != nil {
+		return telemetry.Error(ctx, span, err, "failed to check if app event is duplicate")
+	}
+	if isDuplicate {
+		telemetry.WithAttributes(span, telemetry.AttributeKV{Key: "is-duplicate", Value: true})
+		return nil
+	}
+
+	telemetry.WithAttributes(span,
+		telemetry.AttributeKV{Key: "app-id", Value: hydratedNotification.AppID},
+		telemetry.AttributeKV{Key: "app-name", Value: hydratedNotification.AppName},
+		telemetry.AttributeKV{Key: "service-name", Value: hydratedNotification.ServiceName},
+		telemetry.AttributeKV{Key: "app-revision-id", Value: hydratedNotification.AppRevisionID},
+		telemetry.AttributeKV{Key: "agent-event-id", Value: hydratedNotification.AgentEventID},
+		telemetry.AttributeKV{Key: "agent-detail", Value: hydratedNotification.AgentDetail},
+		telemetry.AttributeKV{Key: "agent-summary", Value: hydratedNotification.AgentSummary},
+	)
+
+	if !strings.Contains(hydratedNotification.AgentSummary, "job run") {
+		// 4. hydrate notification with k8s deployment info, only if this isn't a job run
+		hydratedNotification, err = hydrateNotificationWithDeployment(ctx, hydrateNotificationWithDeploymentInput{
+			Notification:       hydratedNotification,
+			DeploymentTargetId: inp.DeploymentTargetID,
+			Namespace:          inp.Namespace,
+			K8sAgent:           inp.K8sAgent,
+			EventRepo:          inp.EventRepo,
+		})
+		if err != nil {
+			return telemetry.Error(ctx, span, err, "failed to hydrate notification with deployment")
+		}
+	}
+
+	// 5. hydrate notification with a Porter error containing user-facing details
+	hydratedNotification = hydrateNotificationWithError(ctx, hydratedNotification)
+
+	// 6. based on notification + k8s deployment, update the status of the matching deploy event
+	if hydratedNotification.Deployment.Status == DeploymentStatus_Failure ||
+		(hydratedNotification.Deployment.Status == DeploymentStatus_Pending &&
+			errorCodeIndicatesDeploymentFailure(hydratedNotification.Error.Code)) {
+		err = updateDeployEvent(ctx, updateDeployEventInput{
+			Notification: hydratedNotification,
+			EventRepo:    inp.EventRepo,
+			Status:       PorterAppEventStatus_Failed,
+		})
+		if err != nil {
+			return telemetry.Error(ctx, span, err, "failed to update deploy event matching notification")
+		}
+	}
+
+	// 7. save notification to db
+	err = saveNotification(ctx, hydratedNotification, inp.EventRepo, inp.DeploymentTargetID)
+	if err != nil {
+		return telemetry.Error(ctx, span, err, "failed to save notification")
+	}
+
+	return nil
+}
+
+// Notification is a struct that contains all actionable information from an app event
+type Notification struct {
+	// AppID is the ID of the app
+	AppID string `json:"app_id"`
+	// AppName is the name of the app
+	AppName string `json:"app_name"`
+	// ServiceName is the name of the service
+	ServiceName string `json:"service_name"`
+	// AppRevisionID is the ID of the app revision that the notification belongs to
+	AppRevisionID string `json:"app_revision_id"`
+	// AgentEventID is the ID of the agent event, used for deduping
+	AgentEventID int `json:"agent_event_id"`
+	// AgentDetail is the raw detail of the agent event
+	AgentDetail string `json:"agent_detail"`
+	// AgentSummary is the raw summary of the agent event
+	AgentSummary string `json:"agent_summary"`
+	// Error is the Porter error parsed from the agent event
+	Error porter_error.PorterError `json:"error"`
+	// Deployment is the deployment metadata, used to determine if the notification occurred during deployment or after
+	Deployment Deployment `json:"deployment"`
+	// Timestamp is the time that the notification was created
+	Timestamp time.Time `json:"timestamp"`
+	// ID is the ID of the notification
+	ID uuid.UUID `json:"id"`
+}
+
+// agentEventToNotification converts an app event to a notification
+func agentEventToNotification(appEventMetadata AppEventMetadata) Notification {
+	// There is a discrepancy between the predeploy naming; the front-end calls it "pre-deploy", but the job name is "predeploy"
+	// This is a hack to make sure that the front-end can still parse the notification
+	// TODO: rename the job to pre-deploy on the backend to match the front-end UI representation
+	serviceName := appEventMetadata.ServiceName
+	if serviceName == "predeploy" {
+		serviceName = "pre-deploy"
+	}
+
+	notification := Notification{
+		AppID:         appEventMetadata.AppID,
+		AppName:       appEventMetadata.AppName,
+		ServiceName:   serviceName,
+		AgentEventID:  appEventMetadata.AgentEventID,
+		AgentDetail:   appEventMetadata.Detail,
+		AgentSummary:  appEventMetadata.Summary,
+		AppRevisionID: appEventMetadata.AppRevisionID,
+		Deployment:    Deployment{Status: DeploymentStatus_Unknown},
+		Timestamp:     time.Now().UTC(),
+		ID:            uuid.New(),
+	}
+	return notification
+}

+ 123 - 0
internal/porter_app/notifications/porter_error/codes.go

@@ -0,0 +1,123 @@
+package porter_error
+
+import (
+	"regexp"
+	"strings"
+)
+
+// PorterError is the translation of a generic error from the agent into an actionable error for the user
+type PorterError struct {
+	// Code is the error code that can be used to determine the type of error
+	Code PorterErrorCode `json:"code"`
+	// Summary is a short description of the error
+	Summary string `json:"summary"`
+	// Detail is a longer description of the error
+	Detail string `json:"detail"`
+	// MitigationSteps are the steps that can be taken to resolve the error
+	MitigationSteps string `json:"mitigation_steps"`
+	// Documentation is a list of links to documentation that can be used to resolve the error
+	Documentation []string `json:"documentation"`
+}
+
+// PorterErrorCode is the error code that can be used to determine the type of error
+type PorterErrorCode int
+
+const (
+	// PorterErrorCode_Unknown is the default error code
+	PorterErrorCode_Unknown PorterErrorCode = 0
+	// PorterErrorCode_NonZeroExitCode is the error code for a generic non-zero exit code
+	PorterErrorCode_NonZeroExitCode PorterErrorCode = 10
+	// PorterErrorCode_NonZeroExitCode_SIGKILL is the error code for a non-zero exit code due to a SIGKILL
+	PorterErrorCode_NonZeroExitCode_SIGKILL PorterErrorCode = 11
+	// PorterErrorCode_NonZeroExitCode_InvalidStartCommand is the error code for a non-zero exit code due to an invalid start command
+	PorterErrorCode_NonZeroExitCode_InvalidStartCommand PorterErrorCode = 12
+	// PorterErrorCode_NonZeroExitCode_CommonIssues is the error code for a non-zero exit code due to common issues
+	PorterErrorCode_NonZeroExitCode_CommonIssues PorterErrorCode = 13
+	// PorterErrorCode_LivenessHealthCheck is the error code for a failed liveness health check
+	PorterErrorCode_LivenessHealthCheck PorterErrorCode = 20
+	// PorterErrorCode_ReadinessHealthCheck is the error code for a failed readiness health check
+	PorterErrorCode_ReadinessHealthCheck PorterErrorCode = 30
+	// PorterErrorCode_RestartedDueToError is the error code for a restart due to an error
+	PorterErrorCode_RestartedDueToError PorterErrorCode = 40
+	// PorterErrorCode_InvalidImageError is the error code for an invalid image
+	PorterErrorCode_InvalidImageError PorterErrorCode = 50
+	// PorterErrorCode_MemoryLimitExceeded is the error code for a memory limit exceeded
+	PorterErrorCode_MemoryLimitExceeded PorterErrorCode = 60
+	// PorterErrorCode_MemoryLimitExceeded_ScaleUp is the error code for a memory limit exceeded when scaling up
+	PorterErrorCode_MemoryLimitExceeded_ScaleUp PorterErrorCode = 61
+	// PorterErrorCode_CPULimitExceeded is the error code for a CPU limit exceeded
+	PorterErrorCode_CPULimitExceeded PorterErrorCode = 70
+	// PorterErrorCode_CPULimitExceeded_ScaleUp is the error code for a CPU limit exceeded when scaling up
+	PorterErrorCode_CPULimitExceeded_ScaleUp PorterErrorCode = 71
+	// PorterErrorCode_CannotBeScheduled is the error code for a pod that cannot be scheduled
+	PorterErrorCode_CannotBeScheduled PorterErrorCode = 80
+)
+
+// ErrorCode parses the agent summary and possibly the detail (if it needs supplemental info) to return a standard Porter error code
+func ErrorCode(agentSummary, agentDetail string) PorterErrorCode {
+	errorCode := PorterErrorCode_Unknown
+
+	if strings.Contains(agentSummary, "non-zero exit code") {
+		return nonZeroExitCodeErrorCode(agentDetail)
+	}
+
+	if strings.Contains(agentSummary, "liveness health check") {
+		return PorterErrorCode_LivenessHealthCheck
+	}
+
+	if strings.Contains(agentSummary, "readiness health check") {
+		return PorterErrorCode_ReadinessHealthCheck
+	}
+
+	if strings.Contains(agentSummary, "restarted due to an error") {
+		return PorterErrorCode_RestartedDueToError
+	}
+
+	if strings.Contains(agentSummary, "invalid image") {
+		return PorterErrorCode_InvalidImageError
+	}
+
+	if strings.Contains(agentSummary, "ran out of memory") {
+		return PorterErrorCode_MemoryLimitExceeded
+	}
+
+	if strings.Contains(agentSummary, "requesting too much memory and cannot scale up") {
+		return PorterErrorCode_MemoryLimitExceeded_ScaleUp
+	}
+
+	if strings.Contains(agentSummary, "requesting more cpu than is available") {
+		return PorterErrorCode_CPULimitExceeded
+	}
+
+	if strings.Contains(agentSummary, "requesting too much cpu and cannot scale up") {
+		return PorterErrorCode_CPULimitExceeded_ScaleUp
+	}
+
+	if strings.Contains(agentSummary, "cannot be scheduled") {
+		return PorterErrorCode_CannotBeScheduled
+	}
+
+	return errorCode
+}
+
+// nonZeroExitCodeErrorCode parses the agent detail for non-zero exit code errors to return a standard Porter error code
+func nonZeroExitCodeErrorCode(agentDetail string) PorterErrorCode {
+	errorCode := PorterErrorCode_NonZeroExitCode
+	regex := regexp.MustCompile(restartedWithErrorCodePattern)
+	matches := regex.FindStringSubmatch(agentDetail)
+	if len(matches) != 2 {
+		return errorCode
+	}
+
+	exitCode := matches[1]
+	switch exitCode {
+	case "1":
+		return PorterErrorCode_NonZeroExitCode_CommonIssues
+	case "127":
+		return PorterErrorCode_NonZeroExitCode_InvalidStartCommand
+	case "137":
+		return PorterErrorCode_NonZeroExitCode_SIGKILL
+	default:
+		return errorCode
+	}
+}

+ 372 - 0
internal/porter_app/notifications/porter_error/providers.go

@@ -0,0 +1,372 @@
+package porter_error
+
+import (
+	"fmt"
+	"regexp"
+	"strings"
+)
+
+const restartedWithErrorCodePattern = `restarted with exit code (\S+)`
+
+// ErrorDetailsProvider is the parent interface for populating user-facing info about a Porter Error.
+type ErrorDetailsProvider interface {
+	// Detail returns the error detail for the given error. E.g. "The service restarted with exit code 137."
+	Detail(rawAgentDetail string) string
+	// MitigationSteps returns the mitigation steps for the given error. E.g. "Please make sure that your service handles graceful shutdown when it receives a SIGTERM signal."
+	MitigationSteps(rawAgentDetail string) string
+	// Documentation returns the documentation links that would help with troubleshooting the given error.
+	Documentation(rawAgentDetail string) []string
+}
+
+// ErrorCodeToProvider maps PorterErrorCode to their respective ErrorDetailsProvider implementations.
+var ErrorCodeToProvider = map[PorterErrorCode]ErrorDetailsProvider{
+	PorterErrorCode_NonZeroExitCode:                     NonZeroExitCodeErrorProvider{},
+	PorterErrorCode_NonZeroExitCode_SIGKILL:             NonZeroExitCodeErrorProvider{},
+	PorterErrorCode_NonZeroExitCode_InvalidStartCommand: NonZeroExitCodeErrorProvider{},
+	PorterErrorCode_NonZeroExitCode_CommonIssues:        NonZeroExitCodeErrorProvider{},
+	PorterErrorCode_LivenessHealthCheck:                 LivenessHealthCheckErrorProvider{},
+	PorterErrorCode_ReadinessHealthCheck:                ReadinessHealthCheckErrorProvider{},
+	PorterErrorCode_RestartedDueToError:                 RestartedDueToErrorProvider{},
+	PorterErrorCode_InvalidImageError:                   InvalidImageErrorProvider{},
+	PorterErrorCode_MemoryLimitExceeded:                 MemoryLimitExceededErrorProvider{},
+	PorterErrorCode_MemoryLimitExceeded_ScaleUp:         MemoryLimitExceededScaleUpErrorProvider{},
+	PorterErrorCode_CPULimitExceeded:                    CPULimitExceededErrorProvider{},
+	PorterErrorCode_CPULimitExceeded_ScaleUp:            CPULimitExceededScaleUpErrorProvider{},
+	PorterErrorCode_CannotBeScheduled:                   CannotBeScheduledErrorProvider{},
+}
+
+// NonZeroExitCodeErrorProvider provides error details for NonZeroExitCode errors.
+type NonZeroExitCodeErrorProvider struct{}
+
+// Detail returns the error detail for NonZeroExitCode errors, parsing out the exit code from the agent event.
+func (e NonZeroExitCodeErrorProvider) Detail(rawAgentDetail string) string {
+	humanReadableDetail := rawAgentDetail
+	// Example detail from the agent: "restarted with exit code 137"
+	// We want to get the exit code
+	regex := regexp.MustCompile(restartedWithErrorCodePattern)
+	matches := regex.FindStringSubmatch(humanReadableDetail)
+	if len(matches) != 2 {
+		return humanReadableDetail
+	}
+
+	exitCode := matches[1]
+	prefix := fmt.Sprintf("The service restarted with exit code %s.", exitCode)
+	switch exitCode {
+	case "137":
+		return fmt.Sprintf("%s This indicates that the service was killed by SIGKILL. The most common reason for this is that your service does not handle graceful shutdown when it receives a SIGTERM signal.", prefix)
+	case "1":
+		return fmt.Sprintf("%s This indicates common issues.", prefix)
+	case "127":
+		return fmt.Sprintf("%s This indicates that the service has a misconfigured start command.", prefix)
+	default:
+		return prefix
+	}
+}
+
+// MitigationSteps returns the mitigation steps for NonZeroExitCode errors, parsing out the exit code from the agent event.
+func (e NonZeroExitCodeErrorProvider) MitigationSteps(rawAgentDetail string) string {
+	mitigationSteps := "Please consult our documentation for further guidance. If you need additional help, please reach out to us at support@porter.run."
+	// Example detail from the agent: "restarted with exit code 137"
+	// We want to get the exit code
+	regex := regexp.MustCompile(restartedWithErrorCodePattern)
+	matches := regex.FindStringSubmatch(rawAgentDetail)
+	if len(matches) != 2 {
+		return mitigationSteps
+	}
+
+	exitCode := matches[1]
+	switch exitCode {
+	case "137":
+		return "Please make sure that your service handles graceful shutdown when it receives a SIGTERM signal. After receiving SIGTERM, your service should close existing connections and terminate with exit code 0."
+	case "1":
+		return "Check container logs for further troubleshooting."
+	case "127":
+		return "Please verify that the service start command is correct and redeploy."
+	default:
+		return mitigationSteps
+	}
+}
+
+// Documentation returns the documentation links for NonZeroExitCode errors, parsing out the exit code from the agent event.
+func (e NonZeroExitCodeErrorProvider) Documentation(rawAgentDetail string) []string {
+	docLinks := []string{
+		"https://docs.porter.run/enterprise/managing-applications/application-troubleshooting#application-issues-and-non-zero-exit-codes",
+	}
+	// Example detail from the agent: "restarted with exit code 137"
+	// We want to get the exit code
+	regex := regexp.MustCompile(restartedWithErrorCodePattern)
+	matches := regex.FindStringSubmatch(rawAgentDetail)
+	if len(matches) != 2 {
+		return docLinks
+	}
+
+	exitCode := matches[1]
+	switch exitCode {
+	case "137":
+		docLinks = append(docLinks, "https://docs.porter.run/enterprise/deploying-applications/zero-downtime-deployments#graceful-shutdown")
+	}
+
+	return docLinks
+}
+
+// LivenessHealthCheckErrorProvider provides error details for LivenessHealthCheck errors.
+type LivenessHealthCheckErrorProvider struct{}
+
+// Detail returns the error detail for LivenessHealthCheck errors, parsing out the healthcheck endpoint from the agent event.
+func (e LivenessHealthCheckErrorProvider) Detail(rawAgentDetail string) string {
+	humanReadableDetail := rawAgentDetail
+	// Example detail from the agent: "...Your liveness health check is set to the path /healthz..."
+	// We want to strip out the path
+	pattern := `Your liveness health check is set to the path (\S+)\.`
+	regex := regexp.MustCompile(pattern)
+	matches := regex.FindStringSubmatch(humanReadableDetail)
+	if len(matches) != 2 {
+		return humanReadableDetail
+	}
+	pathValue := matches[1]
+	return fmt.Sprintf("The liveness health check for this service is set to the path %s. The service is not responding with a 200-level response code on this endpoint, so it is continuously restarting.", pathValue)
+}
+
+// MitigationSteps returns the mitigation steps for LivenessHealthCheck errors, parsing out the healthcheck endpoint from the agent event.
+func (e LivenessHealthCheckErrorProvider) MitigationSteps(rawAgentDetail string) string {
+	mitigationSteps := "Please make sure that your service responds with a 200-level response code on the liveness health check endpoint."
+	// Example detail from the agent: "...Your liveness health check is set to the path /healthz..."
+	// We want to strip out the path
+	pattern := `Your liveness health check is set to the path (\S+)\.`
+	regex := regexp.MustCompile(pattern)
+	matches := regex.FindStringSubmatch(rawAgentDetail)
+	if len(matches) != 2 {
+		return mitigationSteps
+	}
+	pathValue := matches[1]
+	return fmt.Sprintf("Please make sure that your service responds with a 200-level response code on the liveness health check endpoint %s.", pathValue)
+}
+
+// Documentation returns the documentation links for LivenessHealthCheck errors.
+func (e LivenessHealthCheckErrorProvider) Documentation(rawAgentDetail string) []string {
+	return []string{
+		"https://docs.porter.run/standard/deploying-applications/zero-downtime-deployments#health-checks",
+		"https://docs.porter.run/standard/deploying-applications/zero-downtime-deployments#graceful-shutdown",
+	}
+}
+
+// ReadinessHealthCheckErrorProvider provides error details for ReadinessHealthCheck errors.
+type ReadinessHealthCheckErrorProvider struct{}
+
+// Detail returns the error detail for ReadinessHealthCheck errors, parsing out the healthcheck endpoint from the agent event.
+func (e ReadinessHealthCheckErrorProvider) Detail(rawAgentDetail string) string {
+	humanReadableDetail := rawAgentDetail
+	// Example detail from the agent: "...Your readiness health check is set to the path /healthz..."
+	// We want to strip out the path
+	pattern := `Your readiness health check is set to the path (\S+)\.`
+	regex := regexp.MustCompile(pattern)
+	matches := regex.FindStringSubmatch(humanReadableDetail)
+	if len(matches) != 2 {
+		return humanReadableDetail
+	}
+	pathValue := matches[1]
+	return fmt.Sprintf("The readiness health check for this service is set to the path %s. The service is not responding with a 200-level response code on this endpoint, so it is continuously restarting.", pathValue)
+}
+
+// MitigationSteps returns the mitigation steps for ReadinessHealthCheck errors, parsing out the healthcheck endpoint from the agent event.
+func (e ReadinessHealthCheckErrorProvider) MitigationSteps(rawAgentDetail string) string {
+	mitigationSteps := "Please make sure that your service responds with a 200-level response code on the readiness health check endpoint."
+	// Example detail from the agent: "...Your readiness health check is set to the path /healthz..."
+	// We want to strip out the path
+	pattern := `Your readiness health check is set to the path (\S+)\.`
+	regex := regexp.MustCompile(pattern)
+	matches := regex.FindStringSubmatch(rawAgentDetail)
+	if len(matches) != 2 {
+		return mitigationSteps
+	}
+	pathValue := matches[1]
+	return fmt.Sprintf("Please make sure that your service responds with a 200-level response code on the readiness health check endpoint %s.", pathValue)
+}
+
+// Documentation returns the documentation links for ReadinessHealthCheck errors.
+func (e ReadinessHealthCheckErrorProvider) Documentation(rawAgentDetail string) []string {
+	return []string{
+		"https://docs.porter.run/standard/deploying-applications/zero-downtime-deployments#health-checks",
+		"https://docs.porter.run/standard/deploying-applications/zero-downtime-deployments#graceful-shutdown",
+	}
+}
+
+// RestartedDueToErrorProvider provides error details for RestartedDueToError errors.
+type RestartedDueToErrorProvider struct{}
+
+// Detail returns the error detail for RestartedDueToError errors.
+func (e RestartedDueToErrorProvider) Detail(rawAgentDetail string) string {
+	return "The service is stuck in a restart loop. This is likely due to other errors."
+}
+
+// MitigationSteps returns the mitigation steps for RestartedDueToError errors.
+func (e RestartedDueToErrorProvider) MitigationSteps(rawAgentDetail string) string {
+	return "Please address other errors if they exist, or check service logs for further troubleshooting."
+}
+
+// Documentation returns the documentation links for RestartedDueToError errors.
+func (e RestartedDueToErrorProvider) Documentation(rawAgentDetail string) []string {
+	return []string{
+		"https://docs.porter.run/enterprise/managing-applications/application-troubleshooting#application-restarts",
+	}
+}
+
+// InvalidImageErrorProvider provides error details for InvalidImageError errors.
+type InvalidImageErrorProvider struct{}
+
+// Detail returns the error detail for InvalidImageError errors.
+func (e InvalidImageErrorProvider) Detail(rawAgentDetail string) string {
+	return "The service cannot pull from the image registry. This is likely due to an invalid image name or bad credentials."
+}
+
+// MitigationSteps returns the mitigation steps for InvalidImageError errors.
+func (e InvalidImageErrorProvider) MitigationSteps(rawAgentDetail string) string {
+	return "Please double check that your image name is correct and that the tag specified exists for that image. If you are attempting to pull from a private registry, please make sure that the registry is correctly linked to your project. You can verify this by going to the Integrations tab -> Docker registry and ensuring that your image repository is listed there."
+}
+
+// Documentation returns the documentation links for InvalidImageError errors.
+func (e InvalidImageErrorProvider) Documentation(rawAgentDetail string) []string {
+	return []string{
+		"https://docs.porter.run/enterprise/managing-applications/application-troubleshooting#image-pull-errors",
+		"https://docs.porter.run/enterprise/deploying-applications/deploying-from-docker-registry",
+	}
+}
+
+// MemoryLimitExceededErrorProvider provides error details for MemoryLimitExceededError errors.
+type MemoryLimitExceededErrorProvider struct{}
+
+// Detail returns the error detail for MemoryLimitExceededError errors, parsing out the memory limit from the agent event.
+func (e MemoryLimitExceededErrorProvider) Detail(rawAgentDetail string) string {
+	detail := "The service exceeded its memory limit. This may be caused by other errors."
+	// Example detail from the agent: "Your service was restarted because it exceeded its memory limit of 4M..."
+	// We want to get the memory limit
+	pattern := `exceeded its memory limit of (\S+)\.`
+	regex := regexp.MustCompile(pattern)
+	matches := regex.FindStringSubmatch(rawAgentDetail)
+	if len(matches) != 2 {
+		return detail
+	}
+	memoryLimit := matches[1]
+
+	return fmt.Sprintf("The service exceeded its memory limit of %s. This may be caused by other errors.", memoryLimit)
+}
+
+// MitigationSteps returns the mitigation steps for MemoryLimitExceededError errors.
+func (e MemoryLimitExceededErrorProvider) MitigationSteps(rawAgentDetail string) string {
+	return "If other errors exist, address them first. Otherwise, please reduce the memory allocation for the service, then redeploy. Alternatively, you can choose a machine type with higher resource limits in the Advanced settings under the Infrastructure tab."
+}
+
+// Documentation returns the documentation links for MemoryLimitExceededError errors.
+func (e MemoryLimitExceededErrorProvider) Documentation(rawAgentDetail string) []string {
+	return []string{
+		"https://docs.porter.run/standard/deploying-applications/runtime-configuration-options/web-applications#resources",
+	}
+}
+
+// MemoryLimitExceededScaleUpErrorProvider provides error details for MemoryLimitExceededError_ScaleUp errors that occur.
+type MemoryLimitExceededScaleUpErrorProvider struct{}
+
+// Detail returns the error detail for MemoryLimitExceededError_ScaleUp errors.
+func (e MemoryLimitExceededScaleUpErrorProvider) Detail(rawAgentDetail string) string {
+	return "The service is requesting more memory than the underlying infrastructure can provide."
+}
+
+// MitigationSteps returns the mitigation steps for MemoryLimitExceededError_ScaleUp errors.
+func (e MemoryLimitExceededScaleUpErrorProvider) MitigationSteps(rawAgentDetail string) string {
+	return "Please reduce the memory allocation for the service, then redeploy. Alternatively, you can choose a machine type with higher resource limits in Infrastructure -> Advanced settings."
+}
+
+// Documentation returns the documentation links for MemoryLimitExceededError_ScaleUp errors.
+func (e MemoryLimitExceededScaleUpErrorProvider) Documentation(rawAgentDetail string) []string {
+	return []string{
+		"https://docs.porter.run/standard/deploying-applications/runtime-configuration-options/web-applications#resources",
+		"https://docs.porter.run/other/kubernetes-101#resources",
+	}
+}
+
+// CPULimitExceededErrorProvider provides error details for CPULimitExceededError errors.
+type CPULimitExceededErrorProvider struct{}
+
+// Detail returns the error detail for CPULimitExceededError errors.
+func (e CPULimitExceededErrorProvider) Detail(rawAgentDetail string) string {
+	return "The service exceeded its CPU limit. This may be caused by other errors."
+}
+
+// MitigationSteps returns the mitigation steps for CPULimitExceededError errors.
+func (e CPULimitExceededErrorProvider) MitigationSteps(rawAgentDetail string) string {
+	return "If other errors exist, address them first. Otherwise, please reduce the CPU allocation for the service, then redeploy. Alternatively, you can choose a machine type with higher resource limits in Infrastructure -> Advanced settings."
+}
+
+// Documentation returns the documentation links for CPULimitExceededError errors.
+func (e CPULimitExceededErrorProvider) Documentation(rawAgentDetail string) []string {
+	return []string{
+		"https://docs.porter.run/standard/deploying-applications/runtime-configuration-options/web-applications#resources",
+	}
+}
+
+// CPULimitExceededScaleUpErrorProvider provides error details for CPULimitExceededError_ScaleUp errors that occur.
+type CPULimitExceededScaleUpErrorProvider struct{}
+
+// Detail returns the error detail for CPULimitExceededError_ScaleUp errors.
+func (e CPULimitExceededScaleUpErrorProvider) Detail(rawAgentDetail string) string {
+	return "The service is requesting more CPU than the underlying infrastructure can provide."
+}
+
+// MitigationSteps returns the mitigation steps for CPULimitExceededError_ScaleUp errors.
+func (e CPULimitExceededScaleUpErrorProvider) MitigationSteps(rawAgentDetail string) string {
+	return "Please reduce the CPU allocation for the service, then redeploy. Alternatively, you can choose a machine type with higher resource limits in Infrastructure -> Advanced settings."
+}
+
+// Documentation returns the documentation links for CPULimitExceededError_ScaleUp errors.
+func (e CPULimitExceededScaleUpErrorProvider) Documentation(rawAgentDetail string) []string {
+	return []string{
+		"https://docs.porter.run/standard/deploying-applications/runtime-configuration-options/web-applications#resources",
+		"https://docs.porter.run/other/kubernetes-101#resources",
+	}
+}
+
+// CannotBeScheduledErrorProvider provides error details for CannotBeScheduledError errors.
+type CannotBeScheduledErrorProvider struct{}
+
+// Detail returns the error detail for CannotBeScheduledError errors.
+func (e CannotBeScheduledErrorProvider) Detail(rawAgentDetail string) string {
+	prefix := "The service cannot be scheduled to run on the underlying infrastructure"
+	lowercaseDetail := strings.ToLower(rawAgentDetail)
+	if strings.Contains(lowercaseDetail, "insufficient cpu") && strings.Contains(lowercaseDetail, "insufficient memory") {
+		return fmt.Sprintf("%s because the service is requesting too much CPU and memory.", prefix)
+	}
+	if strings.Contains(lowercaseDetail, "insufficient cpu") {
+		return fmt.Sprintf("%s because the service is requesting too much CPU.", prefix)
+	}
+	if strings.Contains(lowercaseDetail, "Insufficient memory") {
+		return fmt.Sprintf("%s because the service is requesting too much memory.", prefix)
+	}
+
+	return fmt.Sprintf("%s.", prefix)
+}
+
+// MitigationSteps returns the mitigation steps for CannotBeScheduledError errors.
+func (e CannotBeScheduledErrorProvider) MitigationSteps(rawAgentDetail string) string {
+	lowercaseDetail := strings.ToLower(rawAgentDetail)
+	suffix := "Alternatively, you can choose a machine type with higher resource limits in Infrastructure -> Advanced settings."
+
+	if strings.Contains(lowercaseDetail, "insufficient cpu") && strings.Contains(lowercaseDetail, "insufficient memory") {
+		return fmt.Sprintf("Please reduce the CPU and memory allocation for the service, then redeploy. %s", suffix)
+	}
+	if strings.Contains(lowercaseDetail, "insufficient cpu") {
+		return fmt.Sprintf("Please reduce the CPU allocation for the service, then redeploy. %s", suffix)
+	}
+	if strings.Contains(lowercaseDetail, "Insufficient memory") {
+		return fmt.Sprintf("Please reduce the memory allocation for the service, then redeploy. %s", suffix)
+	}
+
+	return fmt.Sprintf("Please try reducing the CPU and memory allocation for the service, then redeploy. %s", suffix)
+}
+
+// Documentation returns the documentation links for CannotBeScheduledError errors.
+func (e CannotBeScheduledErrorProvider) Documentation(rawAgentDetail string) []string {
+	return []string{
+		"https://docs.porter.run/standard/deploying-applications/runtime-configuration-options/web-applications#resources",
+		"https://docs.porter.run/other/kubernetes-101#resources",
+	}
+}

+ 93 - 0
internal/porter_app/notifications/translate.go

@@ -0,0 +1,93 @@
+package notifications
+
+import (
+	"context"
+	"fmt"
+	"regexp"
+	"strings"
+
+	"github.com/porter-dev/porter/internal/porter_app/notifications/porter_error"
+	"github.com/porter-dev/porter/internal/telemetry"
+)
+
+// hydrateNotificationWithError translates information from the agent into a user-facing form
+func hydrateNotificationWithError(ctx context.Context, notification Notification) Notification {
+	ctx, span := telemetry.NewSpan(ctx, "hydrate-notification-with-user-facing-details")
+	defer span.End()
+
+	hydratedNotification := notification
+
+	errorCode := porter_error.ErrorCode(hydratedNotification.AgentSummary, hydratedNotification.AgentDetail)
+	porterError := createError(ctx, errorCode, hydratedNotification.AgentSummary, hydratedNotification.AgentDetail, hydratedNotification.ServiceName)
+
+	hydratedNotification.Error = porterError
+
+	telemetry.WithAttributes(span,
+		telemetry.AttributeKV{Key: "agent-summary", Value: hydratedNotification.AgentSummary},
+		telemetry.AttributeKV{Key: "human-readable-summary", Value: hydratedNotification.Error.Summary},
+		telemetry.AttributeKV{Key: "agent-detail", Value: hydratedNotification.AgentDetail},
+		telemetry.AttributeKV{Key: "human-readable-detail", Value: hydratedNotification.Error.Detail},
+		telemetry.AttributeKV{Key: "error-code", Value: hydratedNotification.Error.Code},
+	)
+
+	return hydratedNotification
+}
+
+// createError creates a PorterError from a PorterErrorCode, falling back to agent info if the error code is unknown
+func createError(ctx context.Context, errorCode porter_error.PorterErrorCode, agentSummary, agentDetail, serviceName string) porter_error.PorterError {
+	ctx, span := telemetry.NewSpan(ctx, "create-error")
+	defer span.End()
+
+	porterError := porter_error.PorterError{
+		Code:            errorCode,
+		Summary:         translateAgentSummary(agentSummary, serviceName),
+		Detail:          strings.ReplaceAll(agentDetail, "application", "service"),
+		MitigationSteps: "",
+		Documentation:   []string{},
+	}
+
+	telemetry.WithAttributes(span,
+		telemetry.AttributeKV{Key: "agent-summary", Value: agentSummary},
+		telemetry.AttributeKV{Key: "agent-detail", Value: agentDetail},
+		telemetry.AttributeKV{Key: "error-code", Value: int(errorCode)},
+	)
+
+	errorDetailsProvider, ok := porter_error.ErrorCodeToProvider[errorCode]
+	if ok {
+		porterError.Detail = errorDetailsProvider.Detail(agentDetail)
+		porterError.MitigationSteps = errorDetailsProvider.MitigationSteps(agentDetail)
+		porterError.Documentation = errorDetailsProvider.Documentation(agentDetail)
+	}
+
+	// if we do not know the error, or the error is a generic non-zero exit code, we report error so that we can handle it later, but we do not block
+	if !ok || errorCode == porter_error.PorterErrorCode_NonZeroExitCode {
+		_ = telemetry.Error(ctx, span, nil, "unhandled error code, passing along raw agent details")
+	}
+
+	return porterError
+}
+
+// translateAgentSummary translates the agent summary to a human readable summary
+// this is necessary until we make updates to the agent
+func translateAgentSummary(agentSummary, serviceName string) string {
+	humanReadableSummary := agentSummary
+	// Example summary from the agent: "Your application test-1 in namespace default has crashed because the application was restarted due to an error"
+	// We want to replace all instances of "application" with "service"
+	pattern := `application (\S+) in namespace (\S+)`
+	regex := regexp.MustCompile(pattern)
+	if regex.MatchString(humanReadableSummary) {
+		humanReadableSummary = regex.ReplaceAllString(humanReadableSummary, fmt.Sprintf("service %s", serviceName))
+	}
+	humanReadableSummary = strings.ReplaceAll(humanReadableSummary, "application", "service")
+	humanReadableSummary = strings.ReplaceAll(humanReadableSummary, "cpu", "CPU")
+	// We just want the reason, so we only take the part after "because "
+	// If we can't parse the summary, we just return the original summary with the replacement done above
+	parts := strings.SplitAfter(humanReadableSummary, "because ")
+	if len(parts) == 2 {
+		humanReadableSummary = parts[1]
+		if len(humanReadableSummary) > 1 {
+			humanReadableSummary = strings.ToUpper(string(humanReadableSummary[0])) + humanReadableSummary[1:]
+		}
+	}
+	return humanReadableSummary
+}

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

@@ -80,6 +80,39 @@ func (repo *PorterAppEventRepository) ListEventsByPorterAppIDAndDeploymentTarget
 	return apps, paginatedResult, nil
 }
 
+// ListBuildDeployEventsByPorterAppIDAndDeploymentTargetID returns a list of events for a given porter app id and deployment target id, withholding notification and app_event type events
+// This is used to display on build, pre-deploy and deploy events in the activity feed
+// TODO: remove this once notifications are stored in a separate table
+func (repo *PorterAppEventRepository) ListBuildDeployEventsByPorterAppIDAndDeploymentTargetID(ctx context.Context, porterAppID uint, deploymentTargetID uuid.UUID, opts ...helpers.QueryOption) ([]*models.PorterAppEvent, helpers.PaginatedResult, error) {
+	ctx, span := telemetry.NewSpan(ctx, "list-build-deploy-events-by-porter-app-id-and-deployment-target-id")
+	defer span.End()
+
+	telemetry.WithAttributes(span,
+		telemetry.AttributeKV{Key: "porter-app-id", Value: porterAppID},
+		telemetry.AttributeKV{Key: "deployment-target-id", Value: deploymentTargetID},
+	)
+
+	apps := []*models.PorterAppEvent{}
+	paginatedResult := helpers.PaginatedResult{}
+
+	id := strconv.Itoa(int(porterAppID))
+	if id == "" {
+		return nil, paginatedResult, telemetry.Error(ctx, span, nil, "invalid porter app id supplied")
+	}
+
+	db := repo.db.Model(&models.PorterAppEvent{})
+	resultDB := db.Where("porter_app_id = ? AND deployment_target_id = ? AND type != 'APP_EVENT' AND type != 'NOTIFICATION'", id, deploymentTargetID).Order("created_at DESC")
+	resultDB = resultDB.Scopes(helpers.Paginate(db, &paginatedResult, opts...))
+
+	if err := resultDB.Find(&apps).Error; err != nil {
+		if !errors.Is(err, gorm.ErrRecordNotFound) {
+			return nil, paginatedResult, telemetry.Error(ctx, span, err, "error finding events by porter app id and deployment target id")
+		}
+	}
+
+	return apps, paginatedResult, nil
+}
+
 func (repo *PorterAppEventRepository) CreateEvent(ctx context.Context, appEvent *models.PorterAppEvent) error {
 	if appEvent.ID == uuid.Nil {
 		appEvent.ID = uuid.New()
@@ -136,6 +169,28 @@ func (repo *PorterAppEventRepository) ReadEvent(ctx context.Context, id uuid.UUI
 	return appEvent, nil
 }
 
+// ReadNotificationsByAppRevisionID returns a list of notifications for a given porter app id and app revision ID
+func (repo *PorterAppEventRepository) ReadNotificationsByAppRevisionID(ctx context.Context, porterAppID uint, appRevisionId string) ([]*models.PorterAppEvent, error) {
+	notifications := []*models.PorterAppEvent{}
+
+	if appRevisionId == "" {
+		return notifications, errors.New("invalid app revision ID supplied")
+	}
+
+	if porterAppID == 0 {
+		return notifications, errors.New("invalid porter app ID supplied")
+	}
+
+	strAppID := strconv.Itoa(int(porterAppID))
+
+	// TODO: make app_revision_id a column in porter_app_event table: https://linear.app/porter/issue/POR-2096/add-app-revision-id-column-to-porter-app-events-table
+	if err := repo.db.Where("porter_app_id = ? AND type = 'NOTIFICATION' AND metadata->>'app_revision_id' = ?", strAppID, appRevisionId).Find(&notifications).Error; err != nil {
+		return notifications, err
+	}
+
+	return notifications, nil
+}
+
 func (repo *PorterAppEventRepository) ReadDeployEventByRevision(ctx context.Context, porterAppID uint, revision float64) (models.PorterAppEvent, error) {
 	appEvent := models.PorterAppEvent{}
 

+ 2 - 0
internal/repository/porter_app_event.go

@@ -13,10 +13,12 @@ type PorterAppEventRepository interface {
 	ListEventsByPorterAppID(ctx context.Context, porterAppID uint, opts ...helpers.QueryOption) ([]*models.PorterAppEvent, helpers.PaginatedResult, error)
 	// ListEventsByPorterAppIDAndDeploymentTargetID returns a list of events for a given porter app id and deployment target id
 	ListEventsByPorterAppIDAndDeploymentTargetID(ctx context.Context, porterAppID uint, deploymentTargetID uuid.UUID, opts ...helpers.QueryOption) ([]*models.PorterAppEvent, helpers.PaginatedResult, error)
+	ListBuildDeployEventsByPorterAppIDAndDeploymentTargetID(ctx context.Context, porterAppID uint, deploymentTargetID uuid.UUID, opts ...helpers.QueryOption) ([]*models.PorterAppEvent, helpers.PaginatedResult, error)
 	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)
 	// ReadDeployEventByAppRevisionID returns a deploy event for a given porter app id and app revision ID
 	ReadDeployEventByAppRevisionID(ctx context.Context, porterAppID uint, appRevisionID string) (models.PorterAppEvent, error)
+	ReadNotificationsByAppRevisionID(ctx context.Context, porterAppID uint, appRevisionID string) ([]*models.PorterAppEvent, error)
 }

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

@@ -27,6 +27,11 @@ func (repo *PorterAppEventRepository) ListEventsByPorterAppIDAndDeploymentTarget
 	return nil, helpers.PaginatedResult{}, errors.New("cannot write database")
 }
 
+// ListBuildDeployEventsByPorterAppIDAndDeploymentTargetID is a test method
+func (repo *PorterAppEventRepository) ListBuildDeployEventsByPorterAppIDAndDeploymentTargetID(ctx context.Context, porterAppID uint, deploymentTargetID uuid.UUID, opts ...helpers.QueryOption) ([]*models.PorterAppEvent, helpers.PaginatedResult, error) {
+	return nil, helpers.PaginatedResult{}, errors.New("cannot write database")
+}
+
 func (repo *PorterAppEventRepository) CreateEvent(ctx context.Context, appEvent *models.PorterAppEvent) error {
 	return errors.New("cannot write database")
 }
@@ -47,3 +52,8 @@ func (repo *PorterAppEventRepository) ReadDeployEventByRevision(ctx context.Cont
 func (repo *PorterAppEventRepository) ReadDeployEventByAppRevisionID(ctx context.Context, porterAppID uint, appRevisionID string) (models.PorterAppEvent, error) {
 	return models.PorterAppEvent{}, errors.New("cannot read database")
 }
+
+// ReadNotificationsByAppRevisionID is a test method
+func (repo *PorterAppEventRepository) ReadNotificationsByAppRevisionID(ctx context.Context, porterAppID uint, appRevisionID string) ([]*models.PorterAppEvent, error) {
+	return nil, errors.New("cannot read database")
+}

Algúns arquivos non se mostraron porque demasiados arquivos cambiaron neste cambio