Kaynağa Gözat

Notifications for revision update failures (#3984)

Feroze Mohideen 2 yıl önce
ebeveyn
işleme
4749b58b76

+ 4 - 6
api/server/handlers/porter_app/current_app_revision.go

@@ -162,14 +162,12 @@ func (c *LatestAppRevisionHandler) ServeHTTP(w http.ResponseWriter, r *http.Requ
 	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
+			telemetry.WithAttributes(span, telemetry.AttributeKV{Key: "notification-conversion-error", Value: err.Error()})
+			continue
 		}
 		if notification == nil {
-			err := telemetry.Error(ctx, span, err, "notification is nil")
-			c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
-			return
+			telemetry.WithAttributes(span, telemetry.AttributeKV{Key: "notification-conversion-error", Value: "notification is nil"})
+			continue
 		}
 		latestNotifications = append(latestNotifications, *notification)
 	}

+ 94 - 17
dashboard/src/lib/porter-apps/notification.ts

@@ -1,28 +1,80 @@
 import _ from "lodash";
 
-import { type PorterAppNotification } from "main/home/app-dashboard/app-view/tabs/activity-feed/events/types";
+import {
+  isRevisionNotification,
+  isServiceNotification,
+  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;
+type BaseClientNotification = {
   id: string;
-  appRevisionId: string;
+  timestamp: string;
+  messages: PorterAppNotification[];
+};
+
+type ClientServiceNotification = BaseClientNotification & {
+  scope: "SERVICE";
   service: ClientService;
+  isDeployRelated: boolean;
+  appRevisionId: string;
+};
+
+type ClientRevisionNotification = BaseClientNotification & {
+  scope: "REVISION";
+  isDeployRelated: boolean;
+  appRevisionId: string;
+};
+
+type ClientApplicationNotification = BaseClientNotification & {
+  scope: "APPLICATION";
+};
+
+export type ClientNotification =
+  | ClientServiceNotification
+  | ClientRevisionNotification
+  | ClientApplicationNotification;
+
+export const isClientServiceNotification = (
+  notification: ClientNotification
+): notification is ClientServiceNotification => {
+  return notification.scope === "SERVICE";
+};
+export const isClientRevisionNotification = (
+  notification: ClientNotification
+): notification is ClientRevisionNotification => {
+  return notification.scope === "REVISION";
 };
 
 export function deserializeNotifications(
   notifications: PorterAppNotification[],
   clientServices: ClientService[]
 ): ClientNotification[] {
+  const revisionNotifications = orderNotificationsByTimestamp(
+    clientRevisionNotifications(notifications),
+    "asc"
+  );
+  const serviceNotifications = orderNotificationsByTimestamp(
+    clientServiceNotifications(notifications, clientServices),
+    "asc"
+  );
+
+  return [...revisionNotifications, ...serviceNotifications];
+}
+
+const clientServiceNotifications = (
+  notifications: PorterAppNotification[],
+  clientServices: ClientService[]
+): ClientServiceNotification[] => {
+  const serviceNotifications = notifications.filter(isServiceNotification);
+
   const notificationsGroupedByService = _.groupBy(
-    notifications,
-    (notification) => notification.service_name
+    serviceNotifications,
+    (notification) => notification.metadata.service_name
   );
 
-  const clientNotifications = clientServices
+  return clientServices
     .filter((svc) => notificationsGroupedByService[svc.name.value] != null)
     .map((svc) => {
       const serviceName = svc.name.value;
@@ -30,26 +82,51 @@ export function deserializeNotifications(
         notificationsGroupedByService[serviceName],
         "asc"
       );
-      const timestamp = messages[0].timestamp;
-      const id = messages[0].id;
+      const parentMessage = messages[0];
+      const timestamp = parentMessage.timestamp;
+      const id = parentMessage.id;
+      const appRevisionId = parentMessage.app_revision_id;
       return {
-        // if the deployment is PENDING for any of the notifications, assume that they are all related to the failing deployment
+        scope: "SERVICE",
+        // if the deployment is PENDING or FAILURE 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"
+            notification.metadata.deployment.status === "PENDING" ||
+            notification.metadata.deployment.status === "FAILURE"
         ),
         timestamp,
         id,
         messages,
-        appRevisionId: messages[0].app_revision_id,
+        appRevisionId,
         service: svc,
       };
     });
+};
 
-  return orderNotificationsByTimestamp(clientNotifications, "asc");
-}
+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 [
+    {
+      scope: "REVISION",
+      id,
+      timestamp,
+      isDeployRelated: true,
+      messages,
+      appRevisionId,
+    },
+  ];
+};
 
 const orderNotificationsByTimestamp = <T extends Array<{ timestamp: string }>>(
   notifications: T,

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

@@ -11,8 +11,10 @@ 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 { isClientRevisionNotification } from "lib/porter-apps/notification";
 
 import api from "shared/api";
+import alert from "assets/alert-warning.svg";
 import deploy from "assets/deploy.png";
 import view_changes from "assets/edit-contained.svg";
 import revert from "assets/fast-backward.svg";
@@ -65,7 +67,8 @@ const DeployEventCard: React.FC<Props> = ({
     projectId,
     clusterId,
   });
-  const { latestRevision, porterApp } = useLatestRevision();
+  const { latestRevision, porterApp, latestNotifications } =
+    useLatestRevision();
 
   const isRevertable = useMemo(() => {
     const latestRevisionNumber = revisionIdToNumber[latestRevision.id];
@@ -96,6 +99,12 @@ const DeployEventCard: React.FC<Props> = ({
     revisionIdToNumber,
   ]);
 
+  const revisionNotificationsExist = useMemo(() => {
+    return latestNotifications
+      .filter(isClientRevisionNotification)
+      .some((n) => n.appRevisionId === event.metadata.app_revision_id);
+  }, [JSON.stringify(latestNotifications)]);
+
   const onRevert = useCallback(async (id: string) => {
     try {
       setIsReverting(true);
@@ -288,6 +297,17 @@ const DeployEventCard: React.FC<Props> = ({
               </ImageTagContainer>
             </>
           ) : null}
+          {revisionNotificationsExist && (
+            <>
+              <Spacer inline x={0.5} />
+              <Tag borderColor="#FFBF00">
+                <Link to={`/apps/${appName}/notifications`} color={"#FFBF00"}>
+                  <TagIcon src={alert} />
+                  Notifications
+                </Link>
+              </Tag>
+            </>
+          )}
         </Container>
         <Container row>
           <Icon height="14px" src={run_for} />

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

@@ -9,6 +9,7 @@ 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 { isClientServiceNotification } from "lib/porter-apps/notification";
 
 import alert from "assets/alert-warning.svg";
 import metrics from "assets/bar-group-03.svg";
@@ -71,10 +72,12 @@ const ServiceStatusDetail: React.FC<Props> = ({
             service.config.domains.length
               ? service.config.domains[0].name.value
               : "";
-          const notificationsExistForService = latestNotifications.some(
-            (n) =>
-              n.service.name.value === key && n.appRevisionId === revisionId
-          );
+          const notificationsExistForService = latestNotifications
+            .filter(isClientServiceNotification)
+            .some(
+              (n) =>
+                n.service.name.value === key && n.appRevisionId === revisionId
+            );
           return (
             <ServiceStatusTableRow key={key}>
               <ServiceStatusTableData width={"200px"}>

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

@@ -43,21 +43,23 @@ const porterAppPreDeployEventMetadataValidator = z.object({
   app_revision_id: z.string(),
   commit_sha: z.string().optional(),
 });
-export const porterAppNotificationEventMetadataValidator = z
-  .object({
-    id: z.string(),
-    app_id: z.string(),
-    app_name: z.string(),
+
+const serviceNoticationValidator = z.object({
+  id: z.string(),
+  app_id: z.string(),
+  app_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([]),
+  }),
+  scope: z.literal("SERVICE"),
+  timestamp: z.string(),
+  metadata: z.object({
     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({
         status: z.literal("PENDING"),
@@ -72,12 +74,68 @@ export const porterAppNotificationEventMetadataValidator = z
         status: z.literal("UNKNOWN"),
       }),
     ]),
-  })
+  }),
+});
+const revisionNotificationValidator = z.object({
+  id: z.string(),
+  app_id: z.string(),
+  app_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([]),
+  }),
+  scope: z.literal("REVISION"),
+  timestamp: z.string(),
+});
+const applicationNotificationValidator = z.object({
+  id: z.string(),
+  app_id: z.string(),
+  app_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([]),
+  }),
+  scope: z.literal("APPLICATION"),
+  timestamp: z.string(),
+});
+
+export const isServiceNotification = (
+  notification: PorterAppNotification
+): notification is z.infer<typeof serviceNoticationValidator> => {
+  return notification.scope === "SERVICE";
+};
+
+export const isApplicationNotification = (
+  notification: PorterAppNotification
+): notification is z.infer<typeof applicationNotificationValidator> => {
+  return notification.scope === "APPLICATION";
+};
+
+export const isRevisionNotification = (
+  notification: PorterAppNotification
+): notification is z.infer<typeof revisionNotificationValidator> => {
+  return notification.scope === "REVISION";
+};
+
+export const porterAppNotificationEventMetadataValidator = z
+  .discriminatedUnion("scope", [
+    serviceNoticationValidator,
+    revisionNotificationValidator,
+    applicationNotificationValidator,
+  ])
   // 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: https://linear.app/porter/issue/POR-2119/standardize-naming-of-pre-deploy
   .transform((obj) => {
-    if (obj.service_name === "predeploy") {
-      obj.service_name = "pre-deploy";
+    if (obj.scope === "SERVICE" && obj.metadata.service_name === "predeploy") {
+      obj.metadata.service_name = "pre-deploy";
     }
     return obj;
   });

+ 68 - 48
dashboard/src/main/home/app-dashboard/app-view/tabs/notifications/NotificationExpandedView.tsx

@@ -10,7 +10,10 @@ 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 {
+  isClientServiceNotification,
+  type ClientNotification,
+} from "lib/porter-apps/notification";
 
 import { feedDate } from "shared/string_utils";
 import calendar from "assets/calendar-02.svg";
@@ -41,55 +44,71 @@ const NotificationExpandedView: React.FC<Props> = ({
   const { showIntercomWithMessage } = useIntercom();
 
   const summary = useMemo(() => {
-    if (notification.isDeployRelated) {
-      return "failed to deploy";
-    } else {
-      return "is unhealthy";
-    }
+    return match(notification)
+      .with({ scope: "REVISION" }, () => {
+        return "The latest version failed to deploy";
+      })
+      .with({ scope: "SERVICE" }, (n) => {
+        return n.isDeployRelated ? "failed to deploy" : "is unhealthy";
+      })
+      .with({ scope: "APPLICATION" }, () => {
+        return "The application failed to deploy";
+      })
+      .otherwise(() => {
+        return "";
+      });
   }, [JSON.stringify(notification)]);
 
   const serviceNames = useMemo(() => {
+    if (!isClientServiceNotification(notification)) {
+      return [];
+    }
     if (notification.service.config.type === "predeploy") {
       return ["predeploy"];
     }
     return [notification.service.name.value];
-  }, [notification.service.name.value]);
+  }, [JSON.stringify(notification)]);
 
   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} />
+            {isClientServiceNotification(notification) && (
+              <>
+                <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>
-          )}
+          {isClientServiceNotification(notification) &&
+            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>
@@ -173,21 +192,22 @@ const NotificationExpandedView: React.FC<Props> = ({
           })}
         </StyledActivityFeed>
         <Spacer y={1} />
-        {notification.service.config.type !== "job" && (
-          <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}
-          />
-        )}
+        {isClientServiceNotification(notification) &&
+          notification.service.config.type !== "job" && (
+            <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>

+ 33 - 18
dashboard/src/main/home/app-dashboard/app-view/tabs/notifications/NotificationTile.tsx

@@ -5,7 +5,10 @@ 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 {
+  isClientServiceNotification,
+  type ClientNotification,
+} from "lib/porter-apps/notification";
 
 import { feedDate } from "shared/string_utils";
 import job from "assets/job.png";
@@ -19,11 +22,21 @@ type Props = {
 
 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";
-    }
+    return match(notification)
+      .with({ scope: "REVISION" }, () => {
+        return "The latest version failed to deploy";
+      })
+      .with({ scope: "SERVICE" }, (n) => {
+        return n.isDeployRelated
+          ? "A service failed to deploy"
+          : "A service is unhealthy";
+      })
+      .with({ scope: "APPLICATION" }, () => {
+        return "The application failed to deploy";
+      })
+      .otherwise(() => {
+        return "";
+      });
   }, [JSON.stringify(notification)]);
 
   return (
@@ -37,18 +50,20 @@ const NotificationTile: React.FC<Props> = ({ notification, onClick }) => {
           <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>
+        {isClientServiceNotification(notification) && (
+          <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"} />

+ 26 - 10
internal/porter_app/notifications/notification.go

@@ -12,26 +12,42 @@ type Notification struct {
 	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 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"`
+	// Scope is the scope of the notification
+	Scope Scope `json:"scope"`
+	// Metadata is the metadata of the notification
+	Metadata Metadata `json:"metadata"`
+}
+
+// Metadata is the metadata of the notification
+type Metadata struct {
+	// Deployment is the deployment metadata, used to determine if the notification occurred during deployment or after
+	Deployment Deployment `json:"deployment,omitempty"`
+	// ServiceName is the name of the service
+	ServiceName string `json:"service_name,omitempty"`
+	// JobRunID is the id of the job run, if the service is a job
+	JobRunID string `json:"job_run_id,omitempty"`
 }
 
+// Scope is the scope of the notification
+type Scope string
+
+const (
+	// Scope_Application indicates that the notification is scoped to the application
+	Scope_Application Scope = "APPLICATION"
+	// Scope_Revision indicates that the notification is scoped to the revision
+	Scope_Revision Scope = "REVISION"
+	// Scope_Service indicates that the notification is scoped to the service
+	Scope_Service Scope = "SERVICE"
+)
+
 // 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