Feroze Mohideen 2 лет назад
Родитель
Сommit
02e45d09c5
22 измененных файлов с 549 добавлено и 101 удалено
  1. 97 32
      api/server/handlers/porter_app/app_notifications.go
  2. 11 0
      dashboard/src/lib/porter-apps/error.ts
  3. 30 20
      dashboard/src/lib/porter-apps/notification.ts
  4. 3 0
      dashboard/src/lib/revisions/types.ts
  5. 3 3
      dashboard/src/main/home/app-dashboard/app-view/AppDataContainer.tsx
  6. 18 8
      dashboard/src/main/home/app-dashboard/app-view/LatestRevisionContext.tsx
  7. 2 2
      dashboard/src/main/home/app-dashboard/app-view/tabs/Notifications.tsx
  8. 37 4
      dashboard/src/main/home/app-dashboard/app-view/tabs/activity-feed/events/cards/DeployEventCard.tsx
  9. 9 0
      dashboard/src/main/home/app-dashboard/app-view/tabs/activity-feed/events/cards/EventCard.tsx
  10. 3 3
      dashboard/src/main/home/app-dashboard/app-view/tabs/activity-feed/events/cards/PreDeployEventCard.tsx
  11. 99 0
      dashboard/src/main/home/app-dashboard/app-view/tabs/activity-feed/events/cards/RollbackEventCard.tsx
  12. 6 5
      dashboard/src/main/home/app-dashboard/app-view/tabs/activity-feed/events/cards/ServiceStatusDetail.tsx
  13. 14 0
      dashboard/src/main/home/app-dashboard/app-view/tabs/activity-feed/events/types.ts
  14. 4 0
      dashboard/src/main/home/app-dashboard/app-view/tabs/notifications/NotificationFeed.tsx
  15. 16 0
      dashboard/src/main/home/app-dashboard/app-view/tabs/notifications/NotificationList.tsx
  16. 46 16
      dashboard/src/main/home/app-dashboard/app-view/tabs/notifications/NotificationTile.tsx
  17. 3 0
      dashboard/src/main/home/app-dashboard/app-view/tabs/notifications/expanded-views/NotificationExpandedView.tsx
  18. 138 5
      dashboard/src/main/home/app-dashboard/app-view/tabs/notifications/expanded-views/RevisionNotificationExpandedView.tsx
  19. 2 2
      dashboard/src/main/home/app-dashboard/app-view/tabs/notifications/expanded-views/ServiceNotificationExpandedView.tsx
  20. 0 1
      dashboard/src/main/home/app-dashboard/validate-apply/revisions-list/GHStatusBanner.tsx
  21. 4 0
      internal/models/app_revision.go
  22. 4 0
      internal/porter_app/revisions.go

+ 97 - 32
api/server/handlers/porter_app/app_notifications.go

@@ -1,6 +1,7 @@
 package porter_app
 
 import (
+	"context"
 	"net/http"
 
 	"github.com/porter-dev/porter/api/server/authz"
@@ -14,6 +15,7 @@ import (
 
 	"github.com/porter-dev/porter/internal/porter_app"
 	"github.com/porter-dev/porter/internal/porter_app/notifications"
+	"github.com/porter-dev/porter/internal/repository"
 	"github.com/porter-dev/porter/internal/telemetry"
 
 	"github.com/porter-dev/porter/api/server/handlers"
@@ -109,48 +111,115 @@ func (c *AppNotificationsHandler) ServeHTTP(w http.ResponseWriter, r *http.Reque
 		return
 	}
 
-	currentAppRevisionReq := connect.NewRequest(&porterv1.CurrentAppRevisionRequest{
-		ProjectId: int64(project.ID),
-		AppId:     int64(appId),
-		DeploymentTargetIdentifier: &porterv1.DeploymentTargetIdentifier{
-			Id: request.DeploymentTargetID,
-		},
+	listAppRevisionsReq := connect.NewRequest(&porterv1.ListAppRevisionsRequest{
+		ProjectId:                  int64(project.ID),
+		AppId:                      int64(appId),
+		DeploymentTargetIdentifier: &porterv1.DeploymentTargetIdentifier{Id: request.DeploymentTargetID},
 	})
 
-	currentAppRevisionResp, err := c.Config().ClusterControlPlaneClient.CurrentAppRevision(ctx, currentAppRevisionReq)
+	listAppRevisionsResp, err := c.Config().ClusterControlPlaneClient.ListAppRevisions(ctx, listAppRevisionsReq)
 	if err != nil {
-		err := telemetry.Error(ctx, span, err, "error getting current app revision from cluster control plane client")
-		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusBadRequest))
+		err = telemetry.Error(ctx, span, err, "error listing app revisions")
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
 		return
 	}
 
-	if currentAppRevisionResp == nil || currentAppRevisionResp.Msg == nil {
-		err := telemetry.Error(ctx, span, err, "current app revision resp is nil")
+	if listAppRevisionsResp == nil || listAppRevisionsResp.Msg == nil {
+		err = telemetry.Error(ctx, span, nil, "list app revisions response is nil")
 		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
 		return
 	}
 
-	appRevision := currentAppRevisionResp.Msg.AppRevision
-	encodedRevision, err := porter_app.EncodedRevisionFromProto(ctx, appRevision)
-	if err != nil {
-		err := telemetry.Error(ctx, span, err, "error encoding revision from proto")
-		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
-		return
+	appRevisionsList := listAppRevisionsResp.Msg.AppRevisions
+
+	latestNotifications := make([]notifications.Notification, 0)
+	encodedRevisions := make([]porter_app.Revision, 0)
+
+	if len(appRevisionsList) > 0 {
+		encodedRevision, err := porter_app.EncodedRevisionFromProto(ctx, appRevisionsList[0])
+		if err != nil {
+			err := telemetry.Error(ctx, span, err, "error getting encoded revision from proto")
+			c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
+			return
+		}
+		encodedRevisions = append(encodedRevisions, encodedRevision)
+
+		// encode the penultimate revision as well in case it is a rollback
+		if len(appRevisionsList) > 1 {
+			penultimateRevision, err := porter_app.EncodedRevisionFromProto(ctx, appRevisionsList[1])
+			if err != nil {
+				err := telemetry.Error(ctx, span, err, "error getting encoded revision from proto")
+				c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
+				return
+			}
+			encodedRevisions = append(encodedRevisions, penultimateRevision)
+		}
 	}
 
-	appRevisionId := encodedRevision.ID
-	appInstanceId := encodedRevision.AppInstanceID
+	if len(encodedRevisions) > 0 {
+		latestNotifications, err = notificationsForRevision(ctx, notificationsForRevisionInput{
+			Revision:                 encodedRevisions[0],
+			PorterAppEventRepository: c.Repo().PorterAppEvent(),
+		})
+		if err != nil {
+			err := telemetry.Error(ctx, span, err, "error getting notifications for revision")
+			c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
+			return
+		}
+
+		// if the penultimate revision is a rollback, get the notifications for that revision as well so we can show the user why the rollback happened
+		if len(encodedRevisions) > 1 && encodedRevisions[1].Status == models.AppRevisionStatus_RollbackSuccessful {
+			rollbackNotifications, err := notificationsForRevision(ctx, notificationsForRevisionInput{
+				Revision:                 encodedRevisions[1],
+				PorterAppEventRepository: c.Repo().PorterAppEvent(),
+			})
+			if err != nil {
+				err := telemetry.Error(ctx, span, err, "error getting notifications for rollback revision")
+				c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
+				return
+			}
+			latestNotifications = append(latestNotifications, rollbackNotifications...)
+		}
+	}
+
+	response := AppNotificationsResponse{
+		Notifications: latestNotifications,
+	}
+
+	c.WriteResult(w, r, response)
+}
+
+type notificationsForRevisionInput struct {
+	Revision                 porter_app.Revision
+	PorterAppEventRepository repository.PorterAppEventRepository
+}
+
+func notificationsForRevision(ctx context.Context, inp notificationsForRevisionInput) ([]notifications.Notification, error) {
+	ctx, span := telemetry.NewSpan(ctx, "notifications-for-revision")
+	defer span.End()
+
 	telemetry.WithAttributes(span,
-		telemetry.AttributeKV{Key: "app-revision-id", Value: appRevisionId},
-		telemetry.AttributeKV{Key: "app-instance-id", Value: appInstanceId},
+		telemetry.AttributeKV{Key: "app-revision-id", Value: inp.Revision.ID},
+		telemetry.AttributeKV{Key: "app-instance-id", Value: inp.Revision.AppInstanceID},
 	)
-	notificationEvents, err := c.Repo().PorterAppEvent().ReadNotificationsByAppRevisionID(ctx, appInstanceId, appRevisionId)
+
+	notificationList := make([]notifications.Notification, 0)
+
+	if inp.Revision.ID == "" {
+		return notificationList, telemetry.Error(ctx, span, nil, "app revision id is missing")
+	}
+
+	if inp.Revision.AppInstanceID == uuid.Nil {
+		return notificationList, telemetry.Error(ctx, span, nil, "app instance id is missing")
+	}
+
+	appRevisionId := inp.Revision.ID
+	appInstanceId := inp.Revision.AppInstanceID
+
+	notificationEvents, err := inp.PorterAppEventRepository.ReadNotificationsByAppRevisionID(ctx, appInstanceId, 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
+		return notificationList, telemetry.Error(ctx, span, err, "error getting notifications from repo")
 	}
-	latestNotifications := make([]notifications.Notification, 0)
 	for _, event := range notificationEvents {
 		notification, err := notifications.NotificationFromPorterAppEvent(event)
 		if err != nil {
@@ -166,12 +235,8 @@ func (c *AppNotificationsHandler) ServeHTTP(w http.ResponseWriter, r *http.Reque
 			telemetry.WithAttributes(span, telemetry.AttributeKV{Key: "notification-conversion-error", Value: "old-notification-format"})
 			continue
 		}
-		latestNotifications = append(latestNotifications, *notification)
+		notificationList = append(notificationList, *notification)
 	}
 
-	response := AppNotificationsResponse{
-		Notifications: latestNotifications,
-	}
-
-	c.WriteResult(w, r, response)
+	return notificationList, nil
 }

+ 11 - 0
dashboard/src/lib/porter-apps/error.ts

@@ -0,0 +1,11 @@
+const ERROR_CODE_UPDATE_FAILED = 90;
+export const ERROR_CODE_APPLICATION_ROLLBACK = 91;
+export const ERROR_CODE_APPLICATION_ROLLBACK_FAILED = 92;
+
+export const ERROR_CODE_TO_SUMMARY: Record<number, string> = {
+  [ERROR_CODE_UPDATE_FAILED]: "The latest version failed to deploy",
+  [ERROR_CODE_APPLICATION_ROLLBACK]:
+    "A version deployment failure triggered an auto-rollback",
+  [ERROR_CODE_APPLICATION_ROLLBACK_FAILED]:
+    "Porter attempted a rollback, but the new deployment failed",
+};

+ 30 - 20
dashboard/src/lib/porter-apps/notification.ts

@@ -6,6 +6,10 @@ import {
   type PorterAppNotification,
 } from "main/home/app-dashboard/app-view/tabs/activity-feed/events/types";
 
+import {
+  ERROR_CODE_APPLICATION_ROLLBACK,
+  ERROR_CODE_APPLICATION_ROLLBACK_FAILED,
+} from "./error";
 import { type ClientService } from "./services";
 
 type BaseClientNotification = {
@@ -23,8 +27,8 @@ export type ClientServiceNotification = BaseClientNotification & {
 
 export type ClientRevisionNotification = BaseClientNotification & {
   scope: "REVISION";
-  isDeployRelated: boolean;
   appRevisionId: string;
+  isRollbackRelated: boolean;
 };
 
 type ClientApplicationNotification = BaseClientNotification & {
@@ -49,14 +53,19 @@ export const isClientRevisionNotification = (
 
 export function deserializeNotifications(
   notifications: PorterAppNotification[],
-  clientServices: ClientService[]
+  latestClientServices: ClientService[],
+  latestRevisionId: string
 ): ClientNotification[] {
   const revisionNotifications = orderNotificationsByTimestamp(
     clientRevisionNotifications(notifications),
     "asc"
   );
   const serviceNotifications = orderNotificationsByTimestamp(
-    clientServiceNotifications(notifications, clientServices),
+    clientServiceNotifications(
+      notifications,
+      latestClientServices,
+      latestRevisionId
+    ),
     "asc"
   );
 
@@ -65,16 +74,19 @@ export function deserializeNotifications(
 
 const clientServiceNotifications = (
   notifications: PorterAppNotification[],
-  clientServices: ClientService[]
+  latestClientServices: ClientService[],
+  latestRevisionId: string
 ): ClientServiceNotification[] => {
-  const serviceNotifications = notifications.filter(isServiceNotification);
+  const serviceNotifications = notifications
+    .filter((n) => n.app_revision_id === latestRevisionId)
+    .filter(isServiceNotification);
 
   const notificationsGroupedByService = _.groupBy(
     serviceNotifications,
     (notification) => notification.metadata.service_name
   );
 
-  return clientServices
+  return latestClientServices
     .filter((svc) => notificationsGroupedByService[svc.name.value] != null)
     .map((svc) => {
       const serviceName = svc.name.value;
@@ -108,24 +120,22 @@ const clientRevisionNotifications = (
   notifications: PorterAppNotification[]
 ): ClientRevisionNotification[] => {
   const revisionNotifications = notifications.filter(isRevisionNotification);
-  const messages = orderNotificationsByTimestamp(revisionNotifications, "asc");
-  if (messages.length === 0) {
-    return [];
-  }
-  const parentMessage = messages[0];
-  const timestamp = parentMessage.timestamp;
-  const id = parentMessage.id;
-  const appRevisionId = parentMessage.app_revision_id;
-  return [
-    {
+
+  return revisionNotifications.map((notification) => {
+    const timestamp = notification.timestamp;
+    const id = notification.id;
+    const appRevisionId = notification.app_revision_id;
+    return {
       scope: "REVISION",
       id,
       timestamp,
-      isDeployRelated: true,
-      messages,
+      messages: [notification],
       appRevisionId,
-    },
-  ];
+      isRollbackRelated:
+        notification.error.code === ERROR_CODE_APPLICATION_ROLLBACK ||
+        notification.error.code === ERROR_CODE_APPLICATION_ROLLBACK_FAILED,
+    };
+  });
 };
 
 const orderNotificationsByTimestamp = <T extends Array<{ timestamp: string }>>(

+ 3 - 0
dashboard/src/lib/revisions/types.ts

@@ -22,6 +22,9 @@ export const appRevisionValidator = z.object({
     "DEPLOYMENT_PROGRESSING",
     "DEPLOYMENT_SUCCESSFUL",
     "DEPLOYMENT_FAILED",
+    "ROLLBACK_SUCCESSFUL",
+    "ROLLBACK_FAILED",
+    "ROLLBACK_SKIPPED",
   ]),
   b64_app_proto: z.string(),
   revision_number: z.number(),

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

@@ -112,7 +112,7 @@ const AppDataContainer: React.FC<AppDataContainerProps> = ({ tabParam }) => {
     servicesFromYaml,
     appEnv,
     setPreviewRevision,
-    latestNotifications,
+    latestClientNotifications,
   } = useLatestRevision();
   const { validateApp, setServiceDeletions } = useAppValidation({
     deploymentTargetID: deploymentTarget.id,
@@ -472,7 +472,7 @@ const AppDataContainer: React.FC<AppDataContainerProps> = ({ tabParam }) => {
   }, [isSubmitting, JSON.stringify(errors)]);
 
   const tabs = useMemo(() => {
-    const numNotifications = latestNotifications.length;
+    const numNotifications = latestClientNotifications.length;
 
     const base = [
       {
@@ -534,7 +534,7 @@ const AppDataContainer: React.FC<AppDataContainerProps> = ({ tabParam }) => {
   }, [
     deploymentTarget.is_preview,
     latestProto.build,
-    latestNotifications.length,
+    latestClientNotifications.length,
   ]);
 
   const formattedPath = (

+ 18 - 8
dashboard/src/main/home/app-dashboard/app-view/LatestRevisionContext.tsx

@@ -40,13 +40,17 @@ import {
   type PopulatedEnvGroup,
 } from "../validate-apply/app-settings/types";
 import { porterAppValidator, type PorterAppRecord } from "./AppView";
-import { porterAppNotificationEventMetadataValidator } from "./tabs/activity-feed/events/types";
+import {
+  porterAppNotificationEventMetadataValidator,
+  type PorterAppNotification,
+} from "./tabs/activity-feed/events/types";
 
 type LatestRevisionContextType = {
   porterApp: PorterAppRecord;
   latestRevision: AppRevision;
   latestProto: PorterApp;
-  latestNotifications: ClientNotification[];
+  latestClientNotifications: ClientNotification[];
+  latestSerializedNotifications: PorterAppNotification[];
   servicesFromYaml: DetectedServices | null;
   clusterId: number;
   projectId: number;
@@ -162,7 +166,7 @@ export const LatestRevisionProvider: React.FC<LatestRevisionProviderProps> = ({
     }
   );
 
-  const { data: { notifications: latestPorterAppNotifications = [] } = {} } =
+  const { data: { notifications: latestSerializedNotifications = [] } = {} } =
     useQuery(
       [
         "appNotifications",
@@ -303,12 +307,17 @@ export const LatestRevisionProvider: React.FC<LatestRevisionProviderProps> = ({
     ].filter(valueExists);
   }, [latestProto, detectedServices]);
 
-  const latestNotifications = useMemo(() => {
+  const latestClientNotifications = useMemo(() => {
+    if (!latestRevision) {
+      return [];
+    }
+
     return deserializeNotifications(
-      latestPorterAppNotifications,
-      latestClientServices
+      latestSerializedNotifications,
+      latestClientServices,
+      latestRevision.id
     );
-  }, [latestPorterAppNotifications, latestClientServices]);
+  }, [latestSerializedNotifications, latestClientServices, latestRevision]);
 
   const loading =
     status === "loading" ||
@@ -349,7 +358,8 @@ export const LatestRevisionProvider: React.FC<LatestRevisionProviderProps> = ({
       value={{
         latestRevision,
         latestProto,
-        latestNotifications,
+        latestClientNotifications,
+        latestSerializedNotifications,
         porterApp,
         clusterId: currentCluster.id,
         projectId: currentProject.id,

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

@@ -5,7 +5,7 @@ import NotificationFeed from "./notifications/NotificationFeed";
 
 const Notifications: React.FC = () => {
   const {
-    latestNotifications,
+    latestClientNotifications,
     projectId,
     clusterId,
     appName,
@@ -15,7 +15,7 @@ const Notifications: React.FC = () => {
 
   return (
     <NotificationFeed
-      notifications={latestNotifications}
+      notifications={latestClientNotifications}
       projectId={projectId}
       clusterId={clusterId}
       appName={appName}

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

@@ -27,6 +27,7 @@ import { type PorterAppDeployEvent } from "../types";
 import { getDuration, getStatusColor, getStatusIcon } from "../utils";
 import { CommitIcon, ImageTagContainer, StyledEventCard } from "./EventCard";
 import { RevertModal } from "./RevertModal";
+import RollbackEventCard from "./RollbackEventCard";
 import ServiceStatusDetail from "./ServiceStatusDetail";
 
 type Props = {
@@ -56,8 +57,19 @@ const DeployEventCard: React.FC<Props> = ({
     id: string;
   } | null>(null);
   const [isReverting, setIsReverting] = useState(false);
+
+  const deployEventIncludesRollback = useMemo(() => {
+    return (
+      event.metadata.rollback_target_app_revision_id != null &&
+      event.metadata.rollback_target_image_tag != null
+    );
+  }, [
+    event.metadata.rollback_target_app_revision_id,
+    event.metadata.rollback_target_image_tag,
+  ]);
+
   const [serviceStatusVisible, setServiceStatusVisible] = useState(
-    showServiceStatusDetail
+    showServiceStatusDetail || deployEventIncludesRollback
   );
 
   const { revisionIdToNumber, numberToRevisionId } = useRevisionList({
@@ -66,9 +78,19 @@ const DeployEventCard: React.FC<Props> = ({
     projectId,
     clusterId,
   });
-  const { latestRevision, porterApp, latestNotifications } =
+  const { latestRevision, porterApp, latestClientNotifications } =
     useLatestRevision();
 
+  const rollbackTargetVersionNumber = useMemo(() => {
+    if (
+      event.metadata.rollback_target_app_revision_id == null ||
+      !revisionIdToNumber[event.metadata.rollback_target_app_revision_id]
+    ) {
+      return 0;
+    }
+    return revisionIdToNumber[event.metadata.rollback_target_app_revision_id];
+  }, [JSON.stringify(revisionIdToNumber)]);
+
   const isRevertable = useMemo(() => {
     const latestRevisionNumber = revisionIdToNumber[latestRevision.id];
     const prevRevisionNumber =
@@ -99,10 +121,10 @@ const DeployEventCard: React.FC<Props> = ({
   ]);
 
   const revisionNotificationsExist = useMemo(() => {
-    return latestNotifications
+    return latestClientNotifications
       .filter(isClientRevisionNotification)
       .some((n) => n.appRevisionId === event.metadata.app_revision_id);
-  }, [JSON.stringify(latestNotifications)]);
+  }, [JSON.stringify(latestClientNotifications)]);
 
   const onRevert = useCallback(async (id: string) => {
     try {
@@ -357,6 +379,17 @@ const DeployEventCard: React.FC<Props> = ({
           />
         </AnimateHeight>
       )}
+      {event.metadata.rollback_target_app_revision_id &&
+        event.metadata.rollback_target_image_tag && (
+          <>
+            <Spacer y={0.5} />
+            <RollbackEventCard
+              imageTag={event.metadata.rollback_target_image_tag}
+              gitRepoName={porterApp.repo_name}
+              rollbackTargetVersionNumber={rollbackTargetVersionNumber}
+            />
+          </>
+        )}
       {revertData && (
         <RevertModal
           closeModal={() => {

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

@@ -55,6 +55,11 @@ const EventCard: React.FC<Props> = ({
             ? `https://www.github.com/${porterApp.repo_name}/commit/${event.metadata.image_tag}`
             : ""
         )
+        .with({ type: "AUTO_ROLLBACK" }, (event) =>
+          event.metadata.image_tag
+            ? `https://www.github.com/${porterApp.repo_name}/commit/${event.metadata.image_tag}`
+            : ""
+        )
         .exhaustive()
     );
   }, [JSON.stringify(event), porterApp]);
@@ -82,6 +87,9 @@ const EventCard: React.FC<Props> = ({
         .with({ type: "DEPLOY" }, (event) =>
           event.metadata.image_tag ? event.metadata.image_tag.slice(0, 7) : ""
         )
+        .with({ type: "AUTO_ROLLBACK" }, (event) =>
+          event.metadata.image_tag ? event.metadata.image_tag.slice(0, 7) : ""
+        )
         .exhaustive()
     );
   }, [JSON.stringify(event), porterApp]);
@@ -122,6 +130,7 @@ const EventCard: React.FC<Props> = ({
         displayCommitSha={displayCommitSha}
       />
     ))
+    .with({ type: "AUTO_ROLLBACK" }, () => null)
     .exhaustive();
 };
 

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

@@ -49,7 +49,7 @@ const PreDeployEventCard: React.FC<Props> = ({
   gitCommitUrl,
   displayCommitSha,
 }) => {
-  const { porterApp, latestNotifications } = useLatestRevision();
+  const { porterApp, latestClientNotifications } = useLatestRevision();
 
   const renderStatusText = (event: PorterAppPreDeployEvent): JSX.Element => {
     const color = getStatusColor(event.status);
@@ -62,7 +62,7 @@ const PreDeployEventCard: React.FC<Props> = ({
   };
 
   const predeployNotificationsExist = useMemo(() => {
-    return latestNotifications
+    return latestClientNotifications
       .filter(isClientServiceNotification)
       .some((notification) => {
         return (
@@ -70,7 +70,7 @@ const PreDeployEventCard: React.FC<Props> = ({
           notification.appRevisionId === event.metadata.app_revision_id
         );
       });
-  }, [JSON.stringify(latestNotifications)]);
+  }, [JSON.stringify(latestClientNotifications)]);
 
   return (
     <StyledEventCard>

+ 99 - 0
dashboard/src/main/home/app-dashboard/app-view/tabs/activity-feed/events/cards/RollbackEventCard.tsx

@@ -0,0 +1,99 @@
+import React from "react";
+import styled from "styled-components";
+
+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 Text from "components/porter/Text";
+
+import pull_request_icon from "assets/pull_request_icon.svg";
+import refresh from "assets/refresh.png";
+import tag_icon from "assets/tag.png";
+
+import { getStatusColor, getStatusIcon } from "../utils";
+import {
+  Code,
+  CommitIcon,
+  ImageTagContainer,
+  StyledEventCard,
+} from "./EventCard";
+
+type Props = {
+  imageTag: string;
+  rollbackTargetVersionNumber: number;
+  gitRepoName?: string;
+};
+
+const RollbackEventCard: React.FC<Props> = ({
+  imageTag,
+  gitRepoName,
+  rollbackTargetVersionNumber,
+}) => {
+  return (
+    <StyledEventCard>
+      <Container row spaced>
+        <Container row>
+          <Icon height="13px" src={refresh} />
+          <Spacer inline width="10px" />
+          <Text>Application auto-rollback</Text>
+        </Container>
+      </Container>
+      <Spacer y={0.5} />
+      <Container>
+        <Container row>
+          <Text>
+            {`One or more services failed to deploy, so Porter automatically
+            triggered a rollback to ${
+              rollbackTargetVersionNumber
+                ? `version ${rollbackTargetVersionNumber}`
+                : "the previous successful deploy"
+            }.`}
+          </Text>
+        </Container>
+        <Spacer y={0.5} />
+        <Container row>
+          <Icon height="12px" src={getStatusIcon("SUCCESS")} />
+          <Spacer inline width="10px" />
+          <Text color={getStatusColor("SUCCESS")}>{`Triggered rollback${
+            imageTag ? " to" : ""
+          }`}</Text>
+          <Spacer inline x={0.5} />
+          {gitRepoName ? (
+            <>
+              <ImageTagContainer>
+                <Link
+                  to={`https://www.github.com/${gitRepoName}/commit/${imageTag}`}
+                  target="_blank"
+                  showTargetBlankIcon={false}
+                >
+                  <CommitIcon src={pull_request_icon} />
+                  <Code>{imageTag}</Code>
+                </Link>
+              </ImageTagContainer>
+            </>
+          ) : imageTag ? (
+            <>
+              <ImageTagContainer hoverable={false}>
+                <TagContainer>
+                  <CommitIcon src={tag_icon} />
+                  <Code>{imageTag}</Code>
+                </TagContainer>
+              </ImageTagContainer>
+            </>
+          ) : null}
+        </Container>
+      </Container>
+    </StyledEventCard>
+  );
+};
+
+export default RollbackEventCard;
+
+const TagContainer = styled.div`
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  column-gap: 1px;
+  padding: 0px 2px;
+`;

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

@@ -41,7 +41,8 @@ const ServiceStatusDetail: React.FC<Props> = ({
   revisionId,
   revisionNumber,
 }) => {
-  const { latestClientServices, latestNotifications } = useLatestRevision();
+  const { latestClientServices, latestClientNotifications } =
+    useLatestRevision();
   const convertEventStatusToCopy = (status: string): string => {
     switch (status) {
       case "PROGRESSING":
@@ -72,7 +73,7 @@ const ServiceStatusDetail: React.FC<Props> = ({
             service.config.domains.length
               ? service.config.domains[0].name.value
               : "";
-          const notificationsExistForService = latestNotifications
+          const notificationsExistForService = latestClientNotifications
             .filter(isClientServiceNotification)
             .some(
               (n) =>
@@ -148,7 +149,7 @@ const ServiceStatusDetail: React.FC<Props> = ({
                           target={"_blank"}
                           showTargetBlankIcon={false}
                         >
-                          <TagIcon src={link} />
+                          <TagIcon src={link} height={"10px"} />
                           External link
                         </Link>
                       </Tag>
@@ -189,7 +190,7 @@ const ServiceStatusTableData = styled.td<{
   ${(props) => props.width && `width: ${props.width};`}
 `;
 
-const TagIcon = styled.img`
-  height: 12px;
+const TagIcon = styled.img<{ height?: string }>`
+  height: ${(props) => props.height ?? "12px"};
   margin-right: 3px;
 `;

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

@@ -20,6 +20,8 @@ const porterAppAppEventMetadataValidator = z.object({
 const porterAppDeployEventMetadataValidator = z.object({
   image_tag: z.string().optional(),
   app_revision_id: z.string(),
+  rollback_target_app_revision_id: z.string().optional(),
+  rollback_target_image_tag: z.string().optional(),
   service_deployment_metadata: z
     .record(
       z.object({
@@ -189,6 +191,15 @@ export const porterAppEventValidator = z.discriminatedUnion("type", [
     porter_app_id: z.number(),
     metadata: porterAppNotificationEventMetadataValidator,
   }),
+  z.object({
+    id: z.string(),
+    created_at: z.string(),
+    updated_at: z.string(),
+    type: z.literal("AUTO_ROLLBACK"),
+    type_external_source: z.string().optional().default(""),
+    porter_app_id: z.number(),
+    metadata: porterAppDeployEventMetadataValidator,
+  }),
 ]);
 
 export const getPorterAppEventsValidator = z
@@ -204,3 +215,6 @@ export type PorterAppAppEvent = PorterAppEvent & { type: "APP_EVENT" };
 export type PorterAppNotificationEvent = PorterAppEvent & {
   type: "NOTIFICATION";
 };
+export type PorterAppRollbackEvent = PorterAppEvent & {
+  type: "AUTO_ROLLBACK";
+};

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

@@ -92,6 +92,10 @@ const NotificationFeed: React.FC<Props> = ({
               `/apps/${appName}/notifications?notification_id=${notification.id}`
             );
           }}
+          projectId={projectId}
+          clusterId={clusterId}
+          appName={appName}
+          deploymentTargetId={deploymentTargetId}
         />
       )}
     </StyledNotificationFeed>

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

@@ -1,6 +1,7 @@
 import React from "react";
 import styled from "styled-components";
 
+import { useRevisionList } from "lib/hooks/useRevisionList";
 import { type ClientNotification } from "lib/porter-apps/notification";
 
 import NotificationTile from "./NotificationTile";
@@ -8,12 +9,26 @@ import NotificationTile from "./NotificationTile";
 type Props = {
   notifications: ClientNotification[];
   onNotificationClick: (notification: ClientNotification) => void;
+  projectId: number;
+  clusterId: number;
+  appName: string;
+  deploymentTargetId: string;
 };
 
 const NotificationList: React.FC<Props> = ({
   notifications,
   onNotificationClick,
+  projectId,
+  clusterId,
+  appName,
+  deploymentTargetId,
 }) => {
+  const { revisionIdToNumber } = useRevisionList({
+    projectId,
+    clusterId,
+    appName,
+    deploymentTargetId,
+  });
   return (
     <StyledNotificationList>
       {notifications.map((notif) => (
@@ -23,6 +38,7 @@ const NotificationList: React.FC<Props> = ({
           onClick={() => {
             onNotificationClick(notif);
           }}
+          revisionIdToNumber={revisionIdToNumber}
         />
       ))}
     </StyledNotificationList>

+ 46 - 16
dashboard/src/main/home/app-dashboard/app-view/tabs/notifications/NotificationTile.tsx

@@ -6,7 +6,9 @@ import Container from "components/porter/Container";
 import Spacer from "components/porter/Spacer";
 import Tag from "components/porter/Tag";
 import Text from "components/porter/Text";
+import { ERROR_CODE_TO_SUMMARY } from "lib/porter-apps/error";
 import {
+  isClientRevisionNotification,
   isClientServiceNotification,
   type ClientNotification,
 } from "lib/porter-apps/notification";
@@ -19,13 +21,32 @@ import worker from "assets/worker.png";
 type Props = {
   notification: ClientNotification;
   onClick: () => void;
+  revisionIdToNumber: Record<string, number>;
 };
 
-const NotificationTile: React.FC<Props> = ({ notification, onClick }) => {
+const NotificationTile: React.FC<Props> = ({
+  notification,
+  onClick,
+  revisionIdToNumber,
+}) => {
+  const matchingVersionNumber = useMemo(() => {
+    if (
+      (isClientRevisionNotification(notification) ||
+        isClientServiceNotification(notification)) &&
+      revisionIdToNumber[notification.appRevisionId]
+    ) {
+      return revisionIdToNumber[notification.appRevisionId];
+    }
+    return 0;
+  }, [JSON.stringify(notification), JSON.stringify(revisionIdToNumber)]);
+
   const summary = useMemo(() => {
     return match(notification)
       .with({ scope: "REVISION" }, () => {
-        return "The latest version failed to deploy";
+        return notification.messages.length &&
+          ERROR_CODE_TO_SUMMARY[notification.messages[0].error.code]
+          ? ERROR_CODE_TO_SUMMARY[notification.messages[0].error.code]
+          : "The latest version failed to deploy";
       })
       .with({ scope: "SERVICE" }, (n) => {
         return n.isDeployRelated
@@ -53,20 +74,29 @@ const NotificationTile: React.FC<Props> = ({ notification, onClick }) => {
           <Text color="helper">{feedDate(notification.timestamp)}</Text>
         </Container>
         <Spacer inline x={0.5} />
-        {isClientServiceNotification(notification) && (
-          <Container row style={{ width: "200px" }}>
-            <Tag>
-              {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} />
-              <Text>{notification.service.name.value}</Text>
-            </Tag>
-          </Container>
-        )}
+        <Container row style={{ gap: "10px" }}>
+          {isClientServiceNotification(notification) && (
+            <Container row>
+              <Tag hoverable={false}>
+                {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} />
+                <Text>{notification.service.name.value}</Text>
+              </Tag>
+            </Container>
+          )}
+          {matchingVersionNumber && (
+            <Container row style={{ width: "200px" }}>
+              <Tag hoverable={false}>
+                <Text>{`Version ${matchingVersionNumber}`}</Text>
+              </Tag>
+            </Container>
+          )}
+        </Container>
       </Container>
       <Container row style={{ paddingRight: "10px" }}>
         <StatusDot color={"#FFBF00"} />

+ 3 - 0
dashboard/src/main/home/app-dashboard/app-view/tabs/notifications/expanded-views/NotificationExpandedView.tsx

@@ -40,6 +40,9 @@ const NotificationExpandedView: React.FC<Props> = ({
         notification={n}
         projectId={projectId}
         appName={appName}
+        deploymentTargetId={deploymentTargetId}
+        clusterId={clusterId}
+        appId={appId}
       />
     ))
     .with({ scope: "APPLICATION" }, () => null) // not implemented yet

+ 138 - 5
dashboard/src/main/home/app-dashboard/app-view/tabs/notifications/expanded-views/RevisionNotificationExpandedView.tsx

@@ -1,19 +1,37 @@
-import React from "react";
+import React, { useMemo } from "react";
+import { PorterApp } from "@porter-dev/api-contracts";
 import styled from "styled-components";
+import { match } from "ts-pattern";
 
 import Button from "components/porter/Button";
 import Container from "components/porter/Container";
 import Spacer from "components/porter/Spacer";
 import Text from "components/porter/Text";
 import { useIntercom } from "lib/hooks/useIntercom";
-import { type ClientRevisionNotification } from "lib/porter-apps/notification";
+import { useRevisionList } from "lib/hooks/useRevisionList";
+import { clientAppFromProto } from "lib/porter-apps";
+import { ERROR_CODE_TO_SUMMARY } from "lib/porter-apps/error";
+import {
+  deserializeNotifications,
+  isClientServiceNotification,
+  type ClientRevisionNotification,
+} from "lib/porter-apps/notification";
 
 import { feedDate } from "shared/string_utils";
+import { valueExists } from "shared/util";
 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";
 
-import { isRevisionNotification } from "../../activity-feed/events/types";
+import { useLatestRevision } from "../../../LatestRevisionContext";
+import {
+  isRevisionNotification,
+  isServiceNotification,
+} from "../../activity-feed/events/types";
+import ServiceMessage from "./messages/ServiceMessage";
 import {
   ExpandedViewContent,
   Message,
@@ -21,27 +39,101 @@ import {
   StyledMessageFeed,
   StyledNotificationExpandedView,
 } from "./NotificationExpandedView";
+import {
+  ServiceNameTag,
+  ServiceTypeIcon,
+} from "./ServiceNotificationExpandedView";
 
 type Props = {
   notification: ClientRevisionNotification;
   projectId: number;
   appName: string;
+  deploymentTargetId: string;
+  clusterId: number;
+  appId: number;
 };
 
 const RevisionNotificationExpandedView: React.FC<Props> = ({
   notification,
   projectId,
   appName,
+  deploymentTargetId,
+  clusterId,
+  appId,
 }) => {
   const { showIntercomWithMessage } = useIntercom();
 
+  const summary = useMemo(() => {
+    return notification.messages.length &&
+      ERROR_CODE_TO_SUMMARY[notification.messages[0].error.code]
+      ? ERROR_CODE_TO_SUMMARY[notification.messages[0].error.code]
+      : "The latest version failed to deploy";
+  }, [JSON.stringify(notification)]);
+
+  const { revisionList } = useRevisionList({
+    appName,
+    deploymentTargetId,
+    projectId,
+    clusterId,
+  });
+
+  const { latestSerializedNotifications } = useLatestRevision();
+
+  // for rollback notifications, we want to show the notifications for a previous version, which may have different client services
+  // therefore, we need to get the client services from that version, and parse the notifications from them
+  const rollbackClientServiceNotifications = useMemo(() => {
+    if (!notification.isRollbackRelated) {
+      return [];
+    }
+
+    const rollbackSourceRevision = revisionList.find(
+      (r) => r.id === notification.appRevisionId
+    );
+    if (!rollbackSourceRevision) {
+      return [];
+    }
+
+    const rollbackProto = PorterApp.fromJsonString(
+      atob(rollbackSourceRevision.b64_app_proto),
+      {
+        ignoreUnknownFields: true,
+      }
+    );
+
+    const rollbackApp = clientAppFromProto({
+      proto: rollbackProto,
+      overrides: null,
+    });
+    const rollbackClientServices = [
+      ...rollbackApp.services,
+      rollbackApp.predeploy?.length ? rollbackApp.predeploy[0] : undefined,
+    ].filter(valueExists);
+
+    const rollbackClientNotifications = deserializeNotifications(
+      latestSerializedNotifications,
+      rollbackClientServices,
+      rollbackSourceRevision.id
+    );
+
+    return (
+      rollbackClientNotifications
+        .filter(isClientServiceNotification)
+        // only show the deploy related notifications. There may be notifications generated by services being torn down during the rollback that we do not care about
+        .filter((n) => n.isDeployRelated)
+    );
+  }, [
+    JSON.stringify(notification),
+    JSON.stringify(revisionList),
+    JSON.stringify(latestSerializedNotifications),
+  ]);
+
   return (
     <StyledNotificationExpandedView>
       <ExpandedViewContent>
         <Container row spaced>
           <Container row>
             <Text size={16} color={"#FFBF00"}>
-              The latest version failed to deploy
+              {summary}
             </Text>
           </Container>
         </Container>
@@ -130,7 +222,48 @@ const RevisionNotificationExpandedView: React.FC<Props> = ({
               );
             })}
         </StyledMessageFeed>
-        <Spacer y={1} />
+        <Spacer y={0.5} />
+        {rollbackClientServiceNotifications.map((notification) => (
+          <div key={notification.id}>
+            <Spacer y={0.5} />
+            <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"}>
+                failed to deploy
+              </Text>
+            </Container>
+            <Spacer y={0.5} />
+            <StyledMessageFeed>
+              {notification.messages
+                .filter(isServiceNotification)
+                .map((message, i) => (
+                  <ServiceMessage
+                    key={message.id}
+                    isFirst={i === 0}
+                    message={message}
+                    service={notification.service}
+                    projectId={projectId}
+                    clusterId={clusterId}
+                    appName={appName}
+                    deploymentTargetId={deploymentTargetId}
+                    appId={appId}
+                    appRevisionId={notification.appRevisionId}
+                    showLiveLogs={false} // do not show live logs because the deployment is already rolled back
+                  />
+                ))}
+            </StyledMessageFeed>
+          </div>
+        ))}
       </ExpandedViewContent>
     </StyledNotificationExpandedView>
   );

+ 2 - 2
dashboard/src/main/home/app-dashboard/app-view/tabs/notifications/expanded-views/ServiceNotificationExpandedView.tsx

@@ -105,7 +105,7 @@ const ServiceNotificationExpandedView: React.FC<Props> = ({
 
 export default ServiceNotificationExpandedView;
 
-const ServiceNameTag = styled.div`
+export const ServiceNameTag = styled.div`
   display: flex;
   justify-content: center;
   padding: 3px 5px;
@@ -115,7 +115,7 @@ const ServiceNameTag = styled.div`
   font-size: 16px;
 `;
 
-const ServiceTypeIcon = styled.img`
+export const ServiceTypeIcon = styled.img`
   height: 16px;
   margin-top: 2px;
 `;

+ 0 - 1
dashboard/src/main/home/app-dashboard/validate-apply/revisions-list/GHStatusBanner.tsx

@@ -81,7 +81,6 @@ const GHStatusBanner: React.FC = () => {
           "IMAGE_AVAILABLE",
           "DEPLOYMENT_PROGRESSING",
           "DEPLOYMENT_SUCCESSFUL",
-          "DEPLOYMENT_FAILED",
           () => true
         )
         .otherwise(() => false)

+ 4 - 0
internal/models/app_revision.go

@@ -45,6 +45,10 @@ const (
 	AppRevisionStatus_DeploymentSuccessful AppRevisionStatus = "DEPLOYMENT_SUCCESSFUL"
 	// AppRevisionStatus_DeploymentFailed is the status for a revision that failed to deploy
 	AppRevisionStatus_DeploymentFailed AppRevisionStatus = "DEPLOYMENT_FAILED"
+	// AppRevisionStatus_RollbackSuccessful is the status for a revision that successfully rolled back
+	AppRevisionStatus_RollbackSuccessful AppRevisionStatus = "ROLLBACK_SUCCESSFUL"
+	// AppRevisionStatus_RollbackFailed is the status for a revision that failed to rollback
+	AppRevisionStatus_RollbackFailed AppRevisionStatus = "ROLLBACK_FAILED"
 	// AppRevisionStatus_ApplyFailed is the status for a revision that failed due to an internal system error
 	AppRevisionStatus_ApplyFailed AppRevisionStatus = "APPLY_FAILED"
 	// AppRevisionStatus_UpdateFailed is the status for a revision that failed due to an internal system error

+ 4 - 0
internal/porter_app/revisions.go

@@ -273,6 +273,10 @@ func appRevisionStatusFromProto(status string) (models.AppRevisionStatus, error)
 		appRevisionStatus = models.AppRevisionStatus_DeploymentSuccessful
 	case string(models.AppRevisionStatus_DeploymentFailed):
 		appRevisionStatus = models.AppRevisionStatus_DeploymentFailed
+	case string(models.AppRevisionStatus_RollbackSuccessful):
+		appRevisionStatus = models.AppRevisionStatus_RollbackSuccessful
+	case string(models.AppRevisionStatus_RollbackFailed):
+		appRevisionStatus = models.AppRevisionStatus_RollbackFailed
 	default:
 		return appRevisionStatus, errors.New("unknown app revision status")
 	}