Feroze Mohideen 2 роки тому
батько
коміт
d3b3f8d1ee

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

@@ -634,11 +634,11 @@ func (p *CreateUpdatePorterAppEventHandler) handleNotification(ctx context.Conte
 	}
 
 	inp := notifications.HandleNotificationInput{
-		RawAppEventMetadata: request.Metadata,
-		EventRepo:           p.Repo().PorterAppEvent(),
-		DeploymentTargetID:  request.DeploymentTargetID,
-		Namespace:           deploymentTarget.Namespace,
-		K8sAgent:            agent,
+		RawAgentEventMetadata: request.Metadata,
+		EventRepo:             p.Repo().PorterAppEvent(),
+		DeploymentTargetID:    request.DeploymentTargetID,
+		Namespace:             deploymentTarget.Namespace,
+		K8sAgent:              agent,
 	}
 
 	err = notifications.HandleNotification(ctx, inp)

+ 11 - 4
dashboard/src/main/home/app-dashboard/app-view/LatestRevisionContext.tsx

@@ -25,11 +25,13 @@ import {
   PopulatedEnvGroup,
   populatedEnvGroup,
 } from "../validate-apply/app-settings/types";
+import { PorterAppNotification, porterAppNotificationEventMetadataValidator } from "./tabs/activity-feed/events/types";
 
 export const LatestRevisionContext = createContext<{
   porterApp: PorterAppRecord;
   latestRevision: AppRevision;
   latestProto: PorterApp;
+  latestNotifications: PorterAppNotification[];
   servicesFromYaml: DetectedServices | null;
   clusterId: number;
   projectId: number;
@@ -96,7 +98,7 @@ export const LatestRevisionProvider = ({
     }
   );
 
-  const { data: latestRevision, status } = useQuery(
+  const { data: {app_revision: latestRevision, notifications: latestNotifications = []} = {}, status } = useQuery(
     [
       "getLatestRevision",
       currentProject?.id,
@@ -106,7 +108,7 @@ export const LatestRevisionProvider = ({
     ],
     async () => {
       if (!appParamsExist) {
-        return;
+        return { app_revision: undefined, notifications: [] };
       }
       const res = await api.getLatestRevision(
         "<token>",
@@ -120,12 +122,16 @@ export const LatestRevisionProvider = ({
         }
       );
 
-      const revisionData = await z
+      const { app_revision, notifications } = await z
         .object({
           app_revision: appRevisionValidator,
+          notifications: z.array(porterAppNotificationEventMetadataValidator)
         })
         .parseAsync(res.data);
-      return revisionData.app_revision;
+      return {
+        app_revision,
+        notifications,
+      };
     },
     {
       enabled: appParamsExist,
@@ -306,6 +312,7 @@ export const LatestRevisionProvider = ({
       value={{
         latestRevision,
         latestProto,
+        latestNotifications,
         porterApp,
         clusterId: currentCluster.id,
         projectId: currentProject.id,

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

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

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

@@ -35,6 +35,15 @@ const porterAppPreDeployEventMetadataValidator = z.object({
     app_revision_id: z.string(),
     commit_sha: z.string().optional(),
 });
+export const porterAppNotificationEventMetadataValidator = z.object({
+    app_id: z.string(),
+    app_name: z.string(),
+    service_name: z.string(),
+    app_revision_id: z.string(),
+    human_readable_detail: z.string(),
+    human_readable_summary: z.string(),
+});
+export type PorterAppNotification = z.infer<typeof porterAppNotificationEventMetadataValidator>;
 export const porterAppEventValidator = z.discriminatedUnion("type", [
     z.object({
         id: z.string(),
@@ -82,7 +91,7 @@ export const porterAppEventValidator = z.discriminatedUnion("type", [
         updated_at: z.string(),
         type: z.literal("NOTIFICATION"),
         porter_app_id: z.number(),
-        metadata: z.any(),
+        metadata: porterAppNotificationEventMetadataValidator,
     }),
 ]);
 
@@ -92,4 +101,5 @@ export type PorterAppEvent = z.infer<typeof porterAppEventValidator>;
 export type PorterAppBuildEvent = PorterAppEvent & { type: 'BUILD' };
 export type PorterAppDeployEvent = PorterAppEvent & { type: 'DEPLOY' };
 export type PorterAppPreDeployEvent = PorterAppEvent & { type: 'PRE_DEPLOY' };
-export type PorterAppAppEvent = PorterAppEvent & { type: 'APP_EVENT' };
+export type PorterAppAppEvent = PorterAppEvent & { type: 'APP_EVENT' };
+export type PorterAppNotificationEvent = PorterAppEvent & { type: 'NOTIFICATION' };

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

@@ -0,0 +1,80 @@
+import React from "react";
+import styled from "styled-components";
+import { PorterAppNotificationEvent } from "../activity-feed/events/types";
+import Text from "components/porter/Text";
+import Spacer from "components/porter/Spacer";
+import document from "assets/document.svg";
+import Button from "components/porter/Button";
+
+type Props = {
+  notification: PorterAppNotificationEvent;
+}
+
+const NotificationExpandedView: React.FC<Props> = ({
+    notification,
+}) => {
+  return (
+    <StyledNotificationExpandedView>
+      <ExpandedViewContent>
+        <Text color="helper">Event ID: {notification.id}</Text>
+        <Spacer y={0.5} />
+        <Text size={16}>{notification.metadata.human_readable_summary}</Text>
+        <Spacer y={0.5} />
+        <Message>
+          <img src={document} />
+          {notification.metadata.human_readable_detail}
+        </Message>
+        <Spacer y={0.5} />
+      </ExpandedViewContent>
+      <ExpandedViewFooter>
+        <Button>Take recommended action</Button>
+      </ExpandedViewFooter>
+    </StyledNotificationExpandedView>
+  );
+};
+
+export default NotificationExpandedView;
+
+const StyledNotificationExpandedView = styled.div`
+width: 100%;
+display: flex;
+flex-direction: column;
+animation: fadeIn 0.3s 0s;
+padding: 15px 20px;
+@keyframes fadeIn {
+  from {
+    opacity: 0;
+  }
+  to {
+    opacity: 1;
+  }
+}
+border-bottom: 1px solid #494b4f;
+border-right: 1px solid #494b4f;
+justify-content: space-between;
+`;
+
+const ExpandedViewContent = styled.div`
+  display: flex;
+  flex-direction: column;
+`;
+
+const Message = styled.div`
+  padding: 20px;
+  background: #26292e;
+  border-radius: 5px;
+  line-height: 1.5em;
+  border: 1px solid #aaaabb33;
+  font-size: 13px;
+  display: flex;
+  align-items: center;
+  > img {
+    width: 13px;
+    margin-right: 20px;
+  }
+`;
+
+const ExpandedViewFooter = styled.div`
+  display: flex;
+  justify-content: flex-end;
+`;

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

@@ -0,0 +1,53 @@
+import React, { useState } from "react";
+import styled from "styled-components";
+import NotificationList from "./NotificationList";
+import NotificationExpandedView from "./NotificationExpandedView";
+import { PorterAppNotificationEvent } from "../activity-feed/events/types";
+
+type Props = {
+    notifications: PorterAppNotificationEvent[];
+};
+
+const NotificationFeed: React.FC<Props> = ({
+    notifications,
+}) => {
+    const [selectedNotification, setSelectedNotification] = useState<PorterAppNotificationEvent | null>(null);
+
+    const handleTileClick = (notification: PorterAppNotificationEvent) => {
+        setSelectedNotification(notification);
+    };
+
+    return (
+        <StyledNotificationFeed>
+            {selectedNotification && (
+                <>
+                    <NotificationList 
+                        onTileClick={handleTileClick} 
+                        notifications={notifications} 
+                        selectedNotification={selectedNotification} 
+                    />
+                    <NotificationExpandedView 
+                        notification={selectedNotification} 
+                    />
+                </>
+            )}
+        </StyledNotificationFeed>
+    );
+};
+
+export default NotificationFeed;
+
+const StyledNotificationFeed = styled.div`
+    display: flex;
+    height: 600px;
+    width: 100%;
+    animation: fadeIn 0.3s 0s;
+    @keyframes fadeIn {
+    from {
+        opacity: 0;
+    }
+    to {
+        opacity: 1;
+    }
+    }
+`;

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

@@ -0,0 +1,63 @@
+import React from "react";
+import styled from "styled-components";
+import { PorterAppNotificationEvent } from "../activity-feed/events/types";
+import NotificationTile from "./NotificationTile";
+
+type Props = {
+    onTileClick: (event: PorterAppNotificationEvent) => void;
+    notifications: PorterAppNotificationEvent[];
+    selectedNotification: PorterAppNotificationEvent;
+};
+
+const NotificationList: React.FC<Props> = ({
+    onTileClick,
+    notifications,
+    selectedNotification,
+}) => {
+    return (
+        <StyledNotificationList>
+            {notifications.map((notif) => (
+                <NotificationTile 
+                    key={notif.id} 
+                    notification={notif} 
+                    selected={notif.id === selectedNotification.id} 
+                    onClick={() => onTileClick(notif)} 
+                />
+            ))}
+        </StyledNotificationList>
+    );
+};
+
+export default NotificationList;
+
+const StyledNotificationList = styled.div`
+    width: 300px;
+    display: flex;
+    flex-direction: column;
+    height: 600px;
+    overflow: auto;
+    border-bottom: 1px solid #494b4f;
+    ::-webkit-scrollbar {
+        width: 3px;
+        :horizontal {
+          height: 3px;
+        }
+      }
+    
+      ::-webkit-scrollbar-corner {
+        width: 3px;
+        background: #ffffff11;
+        color: white;
+      }
+    
+      ::-webkit-scrollbar-track {
+        width: 3px;
+        -webkit-box-shadow: inset 0 0 6px rgba(0, 0, 0, 0.3);
+        box-shadow: inset 0 0 6px rgba(0, 0, 0, 0.3);
+      }
+    
+      ::-webkit-scrollbar-thumb {
+        background-color: darkgrey;
+        outline: 1px solid slategrey;
+      }
+`;

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

@@ -0,0 +1,77 @@
+import React from "react";
+import styled from "styled-components";
+import { PorterAppNotificationEvent } from "../activity-feed/events/types";
+import Spacer from "components/porter/Spacer";
+import Text from "components/porter/Text";
+import { feedDate } from "shared/string_utils";
+
+type Props = {
+  notification: PorterAppNotificationEvent;
+  selected: boolean;
+  onClick: () => void;
+};
+
+const NotificationTile: React.FC<Props> = ({
+  notification,
+  selected,
+  onClick,
+}) => {
+  return (
+    <StyledNotificationTile onClick={onClick} selected={selected}>
+      <NotificationContent>
+        <Text color="helper">{feedDate(notification.created_at)}</Text>
+        <Spacer y={0.5} />
+        <NotificationSummary>{notification.metadata.human_readable_summary}</NotificationSummary>
+        <Spacer y={0.5} />
+        <Text color="helper">Service: <ServiceName>{notification.metadata.service_name}</ServiceName></Text>
+      </NotificationContent>
+    </StyledNotificationTile>
+  );
+};
+
+export default NotificationTile;
+
+const StyledNotificationTile = styled.div<{ selected?: boolean }>`
+  align-items: center;
+  user-select: none;
+  display: flex;
+  padding: 15px 10px;
+  flex-direction: column;
+  align-item: center;
+  justify-content: space-between;
+  height: 200px;
+  cursor: pointer;
+  position: relative;
+  border-radius: 5px;
+  background: ${props => props.selected ? props.theme.clickable.clickedBg : props.theme.clickable.bg};
+  border: ${props => props.selected ? "1px solid #fff" : "1px solid #494b4f"};
+  :hover {
+    border: ${({ selected }) => (!selected && "1px solid #7a7b80")};
+  }
+  animation: fadeIn 0.3s 0s;
+  @keyframes fadeIn {
+    from {
+      opacity: 0;
+    }
+    to {
+      opacity: 1;
+    }
+  }
+`;
+
+const NotificationContent = styled.div`
+  display: flex;
+  flex-direction: column;
+  align-items: flex-start;
+  width: 100%;
+`;
+
+const NotificationSummary = styled.div`
+  color: #ffffff;
+  font-size: 13px;
+  font-weight: 500;
+`;
+
+const ServiceName = styled.span`
+  color: #ffffff;
+`;

+ 2 - 2
internal/porter_app/notifications/app_event.go

@@ -62,8 +62,8 @@ type ServiceDeploymentMetadata struct {
 	Type string `json:"type"`
 }
 
-// parseAppEventMetadata parses raw app event metadata to a AppEventMetadata struct
-func parseAppEventMetadata(metadata map[string]interface{}) (*AppEventMetadata, error) {
+// parseAgentEventMetadata parses raw app event metadata to a AppEventMetadata struct
+func parseAgentEventMetadata(metadata map[string]interface{}) (*AppEventMetadata, error) {
 	appEventMetadata := &AppEventMetadata{}
 
 	bytes, err := json.Marshal(metadata)

+ 4 - 3
internal/porter_app/notifications/deployment.go

@@ -120,6 +120,7 @@ func deploymentStatus(depl v1.Deployment) DeploymentStatus {
 
 var fatalDeploymentDetailSubstrings = []string{
 	"stuck in a restart loop",
+	"restarted with exit code",
 }
 
 func detailIndicatesDeploymentFailure(detail string) bool {
@@ -142,9 +143,9 @@ func translateAgentSummary(notification Notification, status DeploymentStatus) s
 	}
 	humanReadableSummary = strings.ReplaceAll(humanReadableSummary, "application", "service")
 	if status == DeploymentStatus_Pending {
-		humanReadableSummary = strings.ReplaceAll(humanReadableSummary, "has crashed", "is failing to deploy")
-		humanReadableSummary = strings.ReplaceAll(humanReadableSummary, "crashed", "is failing to deploy")
-		humanReadableSummary = strings.ReplaceAll(humanReadableSummary, "is currently experiencing downtime", "is failing to deploy")
+		humanReadableSummary = strings.ReplaceAll(humanReadableSummary, "has crashed", "failed to deploy")
+		humanReadableSummary = strings.ReplaceAll(humanReadableSummary, "crashed", "failed to deploy")
+		humanReadableSummary = strings.ReplaceAll(humanReadableSummary, "is currently experiencing downtime", "failed to deploy")
 	}
 	return humanReadableSummary
 }

+ 13 - 13
internal/porter_app/notifications/notification.go

@@ -10,29 +10,29 @@ import (
 )
 
 type HandleNotificationInput struct {
-	RawAppEventMetadata map[string]any
-	EventRepo           repository.PorterAppEventRepository
-	DeploymentTargetID  string
-	Namespace           string
-	K8sAgent            *kubernetes.Agent
+	RawAgentEventMetadata map[string]any
+	EventRepo             repository.PorterAppEventRepository
+	DeploymentTargetID    string
+	Namespace             string
+	K8sAgent              *kubernetes.Agent
 }
 
-// HandleNotification handles the logic for processing app events (which are currently sent by the porter agent)
+// HandleNotification handles the logic for processing agent events
 func HandleNotification(ctx context.Context, inp HandleNotificationInput) error {
 	ctx, span := telemetry.NewSpan(ctx, "handle-notification")
 	defer span.End()
 
-	// 1. parse app event
-	appEventMetadata, err := parseAppEventMetadata(inp.RawAppEventMetadata)
+	// 1. parse agent event
+	agentEventMetadata, err := parseAgentEventMetadata(inp.RawAgentEventMetadata)
 	if err != nil {
 		return telemetry.Error(ctx, span, err, "failed to unmarshal app event metadata")
 	}
-	if appEventMetadata == nil {
+	if agentEventMetadata == nil {
 		return telemetry.Error(ctx, span, nil, "app event metadata is nil")
 	}
 
-	// 2. convert app event to baseNotification
-	baseNotification := appEventToNotification(*appEventMetadata)
+	// 2. convert agent event to baseNotification
+	baseNotification := agentEventToNotification(*agentEventMetadata)
 
 	// 3. dedupe notification
 	isDuplicate, err := isNotificationDuplicate(ctx, baseNotification, inp.EventRepo, inp.DeploymentTargetID)
@@ -92,8 +92,8 @@ type Notification struct {
 	Deployment           Deployment `json:"deployment"`
 }
 
-// appEventToNotification converts an app event to a notification
-func appEventToNotification(appEventMetadata AppEventMetadata) Notification {
+// agentEventToNotification converts an app event to a notification
+func agentEventToNotification(appEventMetadata AppEventMetadata) Notification {
 	humanReadableDetail := appEventMetadata.Detail
 	humanReadableDetail = strings.ReplaceAll(humanReadableDetail, "application", "service")