Przeglądaj źródła

Notification logs (#4007)

Feroze Mohideen 2 lat temu
rodzic
commit
c55a1c2b02

+ 8 - 16
dashboard/src/components/porter/CollapsibleContainer.tsx

@@ -1,23 +1,15 @@
-import React, { ReactNode } from "react";
-
+import React, { type ReactNode } from "react";
 import { Collapse } from "react-collapse";
-import './collapsible-container.css';
+
+import "./collapsible-container.css";
 
 type Props = {
-    isOpened: boolean;
-    children: ReactNode;
+  isOpened: boolean;
+  children: ReactNode;
 };
 
-const CollapsibleContainer: React.FC<Props> = ({
-    isOpened,
-    children,
-}) => {
-
-  return (
-    <Collapse isOpened={isOpened}>
-        {children}
-    </Collapse>
-  );
+const CollapsibleContainer: React.FC<Props> = ({ isOpened, children }) => {
+  return <Collapse isOpened={isOpened}>{children}</Collapse>;
 };
 
-export default CollapsibleContainer;
+export default CollapsibleContainer;

+ 2 - 2
dashboard/src/lib/porter-apps/notification.ts

@@ -14,14 +14,14 @@ type BaseClientNotification = {
   messages: PorterAppNotification[];
 };
 
-type ClientServiceNotification = BaseClientNotification & {
+export type ClientServiceNotification = BaseClientNotification & {
   scope: "SERVICE";
   service: ClientService;
   isDeployRelated: boolean;
   appRevisionId: string;
 };
 
-type ClientRevisionNotification = BaseClientNotification & {
+export type ClientRevisionNotification = BaseClientNotification & {
   scope: "REVISION";
   isDeployRelated: boolean;
   appRevisionId: string;

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

@@ -2,6 +2,7 @@ import { z } from "zod";
 
 export const appRevisionValidator = z.object({
   status: z.enum([
+    "UNKNOWN",
     "CREATED",
     "IMAGE_AVAILABLE",
     "AWAITING_BUILD_ARTIFACT",

+ 0 - 2
dashboard/src/main/home/app-dashboard/app-view/AppView.tsx

@@ -4,7 +4,6 @@ import styled from "styled-components";
 import { z } from "zod";
 
 import Back from "components/porter/Back";
-import Spacer from "components/porter/Spacer";
 
 import AppDataContainer from "./AppDataContainer";
 import AppHeader from "./AppHeader";
@@ -56,7 +55,6 @@ const AppView: React.FC<Props> = ({ match }) => {
       <StyledExpandedApp>
         <Back to="/apps" />
         <AppHeader />
-        <Spacer y={0.5} />
         <AppDataContainer tabParam={params.tab} />
       </StyledExpandedApp>
     </LatestRevisionProvider>

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

@@ -55,11 +55,16 @@ const serviceNoticationValidator = z.object({
     detail: z.string(),
     mitigation_steps: z.string(),
     documentation: z.array(z.string()).default([]),
+    should_view_logs: z.boolean(),
   }),
   scope: z.literal("SERVICE"),
   timestamp: z.string(),
   metadata: z.object({
-    service_name: z.string(),
+    service_name: z
+      .string()
+      // 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((val) => (val === "predeploy" ? "pre-deploy" : val)),
     deployment: z.discriminatedUnion("status", [
       z.object({
         status: z.literal("PENDING"),
@@ -107,9 +112,12 @@ const applicationNotificationValidator = z.object({
   timestamp: z.string(),
 });
 
+export type PorterAppServiceNotification = z.infer<
+  typeof serviceNoticationValidator
+>;
 export const isServiceNotification = (
   notification: PorterAppNotification
-): notification is z.infer<typeof serviceNoticationValidator> => {
+): notification is PorterAppServiceNotification => {
   return notification.scope === "SERVICE";
 };
 
@@ -125,20 +133,14 @@ export const isRevisionNotification = (
   return notification.scope === "REVISION";
 };
 
-export const porterAppNotificationEventMetadataValidator = z
-  .discriminatedUnion("scope", [
+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.scope === "SERVICE" && obj.metadata.service_name === "predeploy") {
-      obj.metadata.service_name = "pre-deploy";
-    }
-    return obj;
-  });
+  ]
+);
 export type PorterAppNotification = z.infer<
   typeof porterAppNotificationEventMetadataValidator
 >;

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

@@ -1,317 +0,0 @@
-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 {
-  isClientServiceNotification,
-  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(() => {
-    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];
-  }, [JSON.stringify(notification)]);
-
-  return (
-    <StyledNotificationExpandedView>
-      <ExpandedViewContent>
-        <Container row spaced>
-          <Container row>
-            {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>
-          {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>
-          {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} />
-        {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>
-        <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;
-`;

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

@@ -9,7 +9,7 @@ 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 NotificationExpandedView from "./expanded-views/NotificationExpandedView";
 import NotificationList from "./NotificationList";
 
 type Props = {

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

@@ -0,0 +1,123 @@
+import React from "react";
+import styled from "styled-components";
+import { match } from "ts-pattern";
+
+import { type ClientNotification } from "lib/porter-apps/notification";
+
+import RevisionNotificationExpandedView from "./RevisionNotificationExpandedView";
+import ServiceNotificationExpandedView from "./ServiceNotificationExpandedView";
+
+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,
+}) => {
+  return match(notification)
+    .with({ scope: "SERVICE" }, (n) => (
+      <ServiceNotificationExpandedView
+        notification={n}
+        projectId={projectId}
+        clusterId={clusterId}
+        appName={appName}
+        deploymentTargetId={deploymentTargetId}
+        appId={appId}
+      />
+    ))
+    .with({ scope: "REVISION" }, (n) => (
+      <RevisionNotificationExpandedView
+        notification={n}
+        projectId={projectId}
+        appName={appName}
+      />
+    ))
+    .with({ scope: "APPLICATION" }, () => null) // not implemented yet
+    .exhaustive();
+};
+
+export default NotificationExpandedView;
+
+export 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;
+    }
+  }
+`;
+
+export const ExpandedViewContent = styled.div`
+  display: flex;
+  flex-direction: column;
+`;
+
+export 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;
+    }
+  }
+`;
+
+export const NotificationWrapper = styled.div`
+  display: flex;
+  align-items: center;
+  position: relative;
+`;
+
+export const StyledMessageFeed = styled.div`
+  width: 100%;
+  animation: fadeIn 0.3s 0s;
+  display: flex;
+  flex-direction: column;
+  gap: 20px;
+  @keyframes fadeIn {
+    from {
+      opacity: 0;
+    }
+    to {
+      opacity: 1;
+    }
+  }
+`;

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

@@ -0,0 +1,151 @@
+import React from "react";
+import styled from "styled-components";
+
+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 { feedDate } from "shared/string_utils";
+import chat from "assets/chat.svg";
+import document from "assets/document.svg";
+import time from "assets/time.svg";
+
+import { isRevisionNotification } from "../../activity-feed/events/types";
+import {
+  ExpandedViewContent,
+  Message,
+  NotificationWrapper,
+  StyledMessageFeed,
+  StyledNotificationExpandedView,
+} from "./NotificationExpandedView";
+
+type Props = {
+  notification: ClientRevisionNotification;
+  projectId: number;
+  appName: string;
+};
+
+const RevisionNotificationExpandedView: React.FC<Props> = ({
+  notification,
+  projectId,
+  appName,
+}) => {
+  const { showIntercomWithMessage } = useIntercom();
+
+  return (
+    <StyledNotificationExpandedView>
+      <ExpandedViewContent>
+        <Container row spaced>
+          <Container row>
+            <Text size={16} color={"#FFBF00"}>
+              The latest version failed to deploy
+            </Text>
+          </Container>
+        </Container>
+        <Spacer y={0.5} />
+        <StyledMessageFeed>
+          {notification.messages
+            .filter(isRevisionNotification)
+            .map((message, i) => {
+              return (
+                <NotificationWrapper 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} />
+                    <MessageDetailContainer>
+                      {message.error.detail}
+                    </MessageDetailContainer>
+                    <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>
+              );
+            })}
+        </StyledMessageFeed>
+        <Spacer y={1} />
+      </ExpandedViewContent>
+    </StyledNotificationExpandedView>
+  );
+};
+
+export default RevisionNotificationExpandedView;
+
+const MessageDetailContainer = styled.div`
+  background: #000000;
+  border-radius: 5px;
+  padding: 10px;
+  display: flex;
+  width: 100%;
+  border-radius: 5px;
+  border: 1px solid ${({ theme }) => theme.border};
+  align-items: center;
+  font-family: monospace;
+`;

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

@@ -0,0 +1,127 @@
+import React from "react";
+import styled from "styled-components";
+import { match } from "ts-pattern";
+
+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 { type ClientServiceNotification } from "lib/porter-apps/notification";
+
+import calendar from "assets/calendar-02.svg";
+import job from "assets/job.png";
+import web from "assets/web.png";
+import worker from "assets/worker.png";
+
+import { isServiceNotification } from "../../activity-feed/events/types";
+import ServiceMessage from "./messages/ServiceMessage";
+import {
+  ExpandedViewContent,
+  StyledMessageFeed,
+  StyledNotificationExpandedView,
+} from "./NotificationExpandedView";
+
+type Props = {
+  notification: ClientServiceNotification;
+  projectId: number;
+  clusterId: number;
+  appName: string;
+  deploymentTargetId: string;
+  appId: number;
+};
+
+const ServiceNotificationExpandedView: React.FC<Props> = ({
+  notification,
+  projectId,
+  clusterId,
+  appName,
+  deploymentTargetId,
+  appId,
+}) => {
+  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"}>
+              {notification.isDeployRelated
+                ? "failed to deploy"
+                : "is unhealthy"}
+            </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} />
+        <StyledMessageFeed>
+          {notification.messages
+            .filter(isServiceNotification)
+            .map((message, i) => (
+              <ServiceMessage
+                key={i}
+                isFirst={i === 0}
+                message={message}
+                service={notification.service}
+                projectId={projectId}
+                clusterId={clusterId}
+                appName={appName}
+                deploymentTargetId={deploymentTargetId}
+                appId={appId}
+                appRevisionId={notification.appRevisionId}
+                showLiveLogs={notification.isDeployRelated}
+              />
+            ))}
+        </StyledMessageFeed>
+        <Spacer y={1} />
+      </ExpandedViewContent>
+    </StyledNotificationExpandedView>
+  );
+};
+
+export default ServiceNotificationExpandedView;
+
+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 TagIcon = styled.img`
+  height: 16px;
+  margin-right: 3px;
+`;

+ 175 - 0
dashboard/src/main/home/app-dashboard/app-view/tabs/notifications/expanded-views/messages/ServiceMessage.tsx

@@ -0,0 +1,175 @@
+import React, { useMemo, useState } from "react";
+import dayjs from "dayjs";
+
+import Button from "components/porter/Button";
+import CollapsibleContainer from "components/porter/CollapsibleContainer";
+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 ClientService } from "lib/porter-apps/services";
+
+import { feedDate } from "shared/string_utils";
+import chat from "assets/chat.svg";
+import document from "assets/document.svg";
+import time from "assets/time.svg";
+
+import { type PorterAppServiceNotification } from "../../../activity-feed/events/types";
+import { Message, NotificationWrapper } from "../NotificationExpandedView";
+
+type Props = {
+  message: PorterAppServiceNotification;
+  isFirst: boolean;
+  service: ClientService;
+  projectId: number;
+  clusterId: number;
+  appName: string;
+  deploymentTargetId: string;
+  appId: number;
+  appRevisionId: string;
+  showLiveLogs: boolean;
+};
+
+const ServiceMessage: React.FC<Props> = ({
+  isFirst,
+  message,
+  service,
+  projectId,
+  clusterId,
+  appName,
+  deploymentTargetId,
+  appId,
+  appRevisionId,
+  showLiveLogs,
+}) => {
+  const { showIntercomWithMessage } = useIntercom();
+  const [logsVisible, setLogsVisible] = useState<boolean>(isFirst);
+  const serviceNames = useMemo(() => {
+    if (service.config.type === "predeploy") {
+      return ["predeploy"];
+    }
+    return [service.name.value];
+  }, [JSON.stringify(service)]);
+
+  return (
+    <NotificationWrapper>
+      <Message>
+        <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>
+          </>
+        )}
+
+        {service.config.type !== "job" && message.error.should_view_logs && (
+          <>
+            <Container row>
+              <Tag>
+                <i className="material-icons-outlined">
+                  {logsVisible ? "keyboard_arrow_up" : "keyboard_arrow_down"}
+                </i>
+                <Link
+                  onClick={() => {
+                    setLogsVisible(!logsVisible);
+                  }}
+                >
+                  <Text size={16}>Logs</Text>
+                </Link>
+              </Tag>
+            </Container>
+            <CollapsibleContainer isOpened={logsVisible}>
+              <Spacer y={0.5} />
+              <Logs
+                projectId={projectId}
+                clusterId={clusterId}
+                appName={appName}
+                serviceNames={serviceNames}
+                deploymentTargetId={deploymentTargetId}
+                appRevisionId={appRevisionId}
+                logFilterNames={["service_name"]}
+                appId={appId}
+                selectedService={serviceNames[0]}
+                selectedRevisionId={appRevisionId}
+                defaultScrollToBottomEnabled={false}
+                timeRange={
+                  showLiveLogs
+                    ? undefined
+                    : {
+                        startTime: dayjs(message.timestamp).subtract(
+                          1,
+                          "minute"
+                        ),
+                        endTime: dayjs(message.timestamp).add(1, "minute"),
+                      }
+                }
+              />
+            </CollapsibleContainer>
+          </>
+        )}
+      </Message>
+    </NotificationWrapper>
+  );
+};
+
+export default ServiceMessage;

+ 4 - 0
internal/models/app_revision.go

@@ -9,6 +9,8 @@ import (
 type AppRevisionStatus string
 
 const (
+	// AppRevisionStatus_Unknown is the default status for an app revision
+	AppRevisionStatus_Unknown AppRevisionStatus = "UNKNOWN"
 	// AppRevisionStatus_Created is the initial status for a revision when first inserted in database
 	AppRevisionStatus_Created AppRevisionStatus = "CREATED"
 	// AppRevisionStatus_ImageAvailable is the status for a revision that has an image available
@@ -41,6 +43,8 @@ const (
 
 	// 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
+	AppRevisionStatus_UpdateFailed AppRevisionStatus = "UPDATE_FAILED"
 )
 
 // AppRevision represents the full spec for a revision of a porter app

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

@@ -60,6 +60,8 @@ type PorterError struct {
 	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"`
+	// ShouldViewLogs is a boolean indicating whether the user should look at container logs for more information
+	ShouldViewLogs bool `json:"should_view_logs"`
 }
 
 // PorterErrorCode is the error code that can be used to determine the type of error

+ 7 - 4
internal/porter_app/revisions.go

@@ -3,7 +3,7 @@ package porter_app
 import (
 	"context"
 	"encoding/base64"
-	"fmt"
+	"errors"
 	"time"
 
 	"connectrpc.com/connect"
@@ -110,8 +110,9 @@ func EncodedRevisionFromProto(ctx context.Context, appRevision *porterv1.AppRevi
 	b64 := base64.StdEncoding.EncodeToString(encoded)
 
 	status, err := appRevisionStatusFromProto(appRevision.Status)
+	telemetry.WithAttributes(span, telemetry.AttributeKV{Key: "status", Value: string(status)})
 	if err != nil {
-		return revision, telemetry.Error(ctx, span, err, "error getting app revision status from proto")
+		_ = telemetry.Error(ctx, span, nil, "unknown revision type") // flagged as an error for visibility
 	}
 
 	appInstanceIdStr := appRevision.AppInstanceId
@@ -202,7 +203,7 @@ func AttachEnvToRevision(ctx context.Context, inp AttachEnvToRevisionInput) (Rev
 }
 
 func appRevisionStatusFromProto(status string) (models.AppRevisionStatus, error) {
-	var appRevisionStatus models.AppRevisionStatus
+	appRevisionStatus := models.AppRevisionStatus_Unknown
 	switch status {
 	case string(models.AppRevisionStatus_ImageAvailable):
 		appRevisionStatus = models.AppRevisionStatus_ImageAvailable
@@ -234,9 +235,11 @@ func appRevisionStatusFromProto(status string) (models.AppRevisionStatus, error)
 		appRevisionStatus = models.AppRevisionStatus_BuildSuccessful
 	case string(models.AppRevisionStatus_ApplyFailed):
 		appRevisionStatus = models.AppRevisionStatus_ApplyFailed
+	case string(models.AppRevisionStatus_UpdateFailed):
+		appRevisionStatus = models.AppRevisionStatus_UpdateFailed
 
 	default:
-		return appRevisionStatus, fmt.Errorf("unknown app revision status")
+		return appRevisionStatus, errors.New("unknown app revision status")
 	}
 
 	return appRevisionStatus, nil