Browse Source

new debug tab

Feroze Mohideen 2 years ago
parent
commit
c801e5d721

+ 35 - 11
dashboard/src/components/porter/Tooltip.tsx

@@ -2,34 +2,52 @@
 import React, { useState } from "react";
 import styled from "styled-components";
 
-interface TooltipProps {
+type TooltipProps = {
   children: React.ReactNode;
   content: React.ReactNode;
   position?: "top" | "right" | "bottom" | "left";
   hidden?: boolean;
-  width?: string;
-}
+  tooltipContentWidth?: string;
+  backgroundColor?: string;
+  containerWidth?: string;
+};
 
 const Tooltip: React.FC<TooltipProps> = ({
   children,
   content,
   position = "top",
   hidden = false,
-  width,
+  backgroundColor = "#333",
+  tooltipContentWidth,
+  containerWidth,
 }) => {
   const [isVisible, setIsVisible] = useState(false);
 
-  const showTooltip = () => setIsVisible(true);
-  const hideTooltip = () => setIsVisible(false);
+  const showTooltip = (): void => {
+    setIsVisible(true);
+  };
+  const hideTooltip = (): void => {
+    setIsVisible(false);
+  };
 
   if (hidden) {
     return <>{children}</>;
   }
 
   return (
-    <TooltipContainer onMouseEnter={showTooltip} onMouseLeave={hideTooltip}>
+    <TooltipContainer
+      onMouseEnter={showTooltip}
+      onMouseLeave={hideTooltip}
+      width={containerWidth}
+    >
       {isVisible && (
-        <TooltipContent position={position} width={width}>{content}</TooltipContent>
+        <TooltipContent
+          position={position}
+          width={tooltipContentWidth}
+          backgroundColor={backgroundColor}
+        >
+          {content}
+        </TooltipContent>
       )}
       {children}
     </TooltipContainer>
@@ -38,13 +56,19 @@ const Tooltip: React.FC<TooltipProps> = ({
 
 export default Tooltip;
 
-const TooltipContainer = styled.div`
+const TooltipContainer = styled.div<{ width?: string }>`
   position: relative;
   display: inline-flex;
+  ${({ width }) => width && `width: ${width};`}
+  height: 100%;
 `;
 
-const TooltipContent = styled.div<{ position: string, width?: string }>`
-  background-color: #333;
+const TooltipContent = styled.div<{
+  position: string;
+  width?: string;
+  backgroundColor: string;
+}>`
+  background-color: ${({ backgroundColor }) => backgroundColor};
   color: #fff;
   padding: 8px;
   border-radius: 4px;

+ 122 - 71
dashboard/src/lib/hooks/useAppStatus.ts

@@ -1,6 +1,6 @@
 import { useEffect, useState } from "react";
 import _ from "lodash";
-import pluralize from "pluralize";
+import { match } from "ts-pattern";
 import z from "zod";
 
 import api from "shared/api";
@@ -8,15 +8,33 @@ import {
   useWebsockets,
   type NewWebsocketOptions,
 } from "shared/hooks/useWebsockets";
-import { valueExists } from "shared/util";
+
+export type ServiceStatusDescriptor =
+  | "running"
+  | "pending"
+  | "failing"
+  | "unknown";
 
 export type ClientServiceStatus = {
-  status: "running" | "spinningDown" | "failing";
+  status: ServiceStatusDescriptor;
+  serviceName: string;
+  versionStatusList: ClientServiceVersionStatus[];
+};
+
+export type ClientServiceVersionStatus = {
+  status: ServiceStatusDescriptor;
+  revisionId: string;
+  revisionNumber: number;
+  instanceStatusList: ClientServiceVersionInstanceStatus[];
+};
+
+export type ClientServiceVersionInstanceStatus = {
+  status: ServiceStatusDescriptor;
   message: string;
   crashLoopReason: string;
   restartCount: number;
-  revisionId: string;
-  revisionNumber: number;
+  name: string;
+  creationTimestamp: string;
 };
 
 const serviceStatusValidator = z.object({
@@ -30,6 +48,7 @@ const serviceStatusValidator = z.object({
           status: z.enum(["PENDING", "RUNNING", "FAILED"]),
           restart_count: z.number(),
           creation_timestamp: z.string(),
+          name: z.string(),
         })
       ),
     })
@@ -51,7 +70,9 @@ export const useAppStatus = ({
   deploymentTargetId: string;
   appName: string;
   kind?: string;
-}): { serviceVersionStatus: Record<string, ClientServiceStatus[]> } => {
+}): {
+  appServiceStatus: Record<string, ClientServiceStatus>;
+} => {
   const [serviceStatusMap, setServiceStatusMap] = useState<
     Record<string, SerializedServiceStatus>
   >({});
@@ -124,78 +145,108 @@ export const useAppStatus = ({
 
   const deserializeServiceStatus = (
     serviceStatus: SerializedServiceStatus
-  ): ClientServiceStatus[] => {
-    return serviceStatus.revision_status_list
+  ): ClientServiceStatus => {
+    const clientServiceStatus: ClientServiceStatus = {
+      status: "unknown",
+      serviceName: serviceStatus.service_name,
+      versionStatusList: [],
+    };
+
+    const versionStatusList = serviceStatus.revision_status_list
       .sort((a, b) => b.revision_number - a.revision_number)
-      .flatMap((revisionStatus) => {
-        const instancesByStatus = _.groupBy(
-          revisionStatus.instance_status_list,
-          (instance) => instance.status
-        );
-        const runningInstances = instancesByStatus.RUNNING || [];
-        const pendingInstances = instancesByStatus.PENDING || [];
-        const failedInstances = instancesByStatus.FAILED || [];
-        const versionStatuses: ClientServiceStatus[] = [];
-
-        if (runningInstances.length > 0) {
-          versionStatuses.push({
-            status: "running",
-            message: `${runningInstances.length} ${pluralize(
-              "instance",
-              runningInstances.length
-            )} ${pluralize("is", runningInstances.length)} running at Version ${
-              revisionStatus.revision_number
-            }`,
-            crashLoopReason: "",
-            restartCount:
-              _.maxBy(runningInstances, "restart_count")?.restart_count ?? 0,
-            revisionId: revisionStatus.revision_id,
-            revisionNumber: revisionStatus.revision_number,
+      .map((revisionStatus) => {
+        const clientServiceVersionStatus: ClientServiceVersionStatus = {
+          status: "unknown",
+          revisionId: revisionStatus.revision_id,
+          revisionNumber: revisionStatus.revision_number,
+          instanceStatusList: [],
+        };
+
+        const instanceStatusList = revisionStatus.instance_status_list
+          .sort((a, b) => {
+            const aDate = new Date(a.creation_timestamp);
+            const bDate = new Date(b.creation_timestamp);
+            return bDate.getTime() - aDate.getTime();
+          })
+          .map((instanceStatus) => {
+            const status: ServiceStatusDescriptor = match(instanceStatus.status)
+              .with("PENDING", () => "pending" as const)
+              .with("RUNNING", () => "running" as const)
+              .with("FAILED", () => "failing" as const)
+              .otherwise(() => "unknown" as const);
+            const clientServiceVersionInstanceStatus: ClientServiceVersionInstanceStatus =
+              {
+                status,
+                message: "",
+                crashLoopReason: "",
+                restartCount: instanceStatus.restart_count,
+                name: instanceStatus.name,
+                creationTimestamp: instanceStatus.creation_timestamp,
+              };
+
+            if (instanceStatus.status === "PENDING") {
+              clientServiceVersionInstanceStatus.message = `Instance is pending at Version ${revisionStatus.revision_number}`;
+            } else if (instanceStatus.status === "RUNNING") {
+              clientServiceVersionInstanceStatus.message = `Instance is running at Version ${revisionStatus.revision_number}`;
+            } else if (instanceStatus.status === "FAILED") {
+              clientServiceVersionInstanceStatus.message = `Instance is failing at Version ${revisionStatus.revision_number}`;
+            }
+
+            return clientServiceVersionInstanceStatus;
           });
+
+        clientServiceVersionStatus.instanceStatusList = instanceStatusList;
+        if (
+          instanceStatusList.every((instance) => instance.status === "running")
+        ) {
+          clientServiceVersionStatus.status = "running";
         }
-        if (pendingInstances.length > 0) {
-          versionStatuses.push({
-            status: "spinningDown",
-            message: `${pendingInstances.length} ${pluralize(
-              "instance",
-              pendingInstances.length
-            )} ${pluralize(
-              "is",
-              pendingInstances.length
-            )} in a pending state at Version ${revisionStatus.revision_number}`,
-            crashLoopReason: "",
-            restartCount:
-              _.maxBy(pendingInstances, "restart_count")?.restart_count ?? 0,
-            revisionId: revisionStatus.revision_id,
-            revisionNumber: revisionStatus.revision_number,
-          });
+        if (
+          instanceStatusList.every((instance) => instance.status === "pending")
+        ) {
+          clientServiceVersionStatus.status = "pending";
         }
-        if (failedInstances.length > 0) {
-          versionStatuses.push({
-            status: "failing",
-            message: `${failedInstances.length} ${pluralize(
-              "instance",
-              failedInstances.length
-            )} ${pluralize(
-              "is",
-              failedInstances.length
-            )} failing to run Version ${revisionStatus.revision_number}`,
-            crashLoopReason: "",
-            restartCount:
-              _.maxBy(failedInstances, "restart_count")?.restart_count ?? 0,
-            revisionId: revisionStatus.revision_id,
-            revisionNumber: revisionStatus.revision_number,
-          });
+        if (
+          instanceStatusList.every((instance) => instance.status === "failing")
+        ) {
+          clientServiceVersionStatus.status = "failing";
         }
-        return versionStatuses;
-      })
-      .filter(valueExists);
+
+        return clientServiceVersionStatus;
+      });
+
+    clientServiceStatus.versionStatusList = versionStatusList;
+    if (versionStatusList.every((version) => version.status === "running")) {
+      clientServiceStatus.status = "running";
+    }
+    if (versionStatusList.every((version) => version.status === "pending")) {
+      clientServiceStatus.status = "pending";
+    }
+    if (versionStatusList.every((version) => version.status === "failing")) {
+      clientServiceStatus.status = "failing";
+    }
+    return clientServiceStatus;
   };
 
   return {
-    serviceVersionStatus: _.mapValues(
-      serviceStatusMap,
-      deserializeServiceStatus
-    ),
+    appServiceStatus: _.mapValues(serviceStatusMap, deserializeServiceStatus),
   };
 };
+
+export const statusColor = (status: ServiceStatusDescriptor): string => {
+  return match(status)
+    .with("running", () => "#38a88a")
+    .with("failing", () => "#ff0000")
+    .with("pending", () => "#FFA500")
+    .with("unknown", () => "#4797ff")
+    .exhaustive();
+};
+
+export const statusColorLight = (status: ServiceStatusDescriptor): string => {
+  return match(status)
+    .with("running", () => "#4b6850")
+    .with("failing", () => "#FF7F7F")
+    .with("pending", () => "#FFC04C")
+    .with("unknown", () => "#e6f2ff")
+    .exhaustive();
+};

+ 11 - 87
dashboard/src/main/home/app-dashboard/app-view/tabs/StatusTab.tsx

@@ -18,44 +18,18 @@ const StatusTab: React.FC = () => {
     appName,
   } = useLatestRevision();
 
-  const { serviceVersionStatus } = useAppStatus({
+  const { appServiceStatus } = useAppStatus({
     projectId,
     clusterId,
-    serviceNames: latestClientServices.map((s) => s.name.value),
+    serviceNames: latestClientServices
+      .filter((s) => s.config.type === "web" || s.config.type === "worker")
+      .map((s) => s.name.value),
     deploymentTargetId: deploymentTarget.id,
     appName,
   });
 
-  //   const renderLogs = () => {
-  //     return (
-  //       <Logs
-  //         podError={podError}
-  //         key={selectedPod?.metadata?.name}
-  //         selectedPod={selectedPod}
-  //       />
-  //     );
-  //   };
-
-  //   const renderTabs = () => {
-  //     return controllers.map((c, i) => {
-  //       return (
-  //         <ControllerTab
-  //           // handle CronJob case
-  //           key={c.metadata?.uid || c.uid}
-  //           selectedPod={selectedPod}
-  //           selectPod={setSelectedPod}
-  //           selectors={selectors ? [selectors[i]] : null}
-  //           controller={c}
-  //           isLast={i === controllers?.length - 1}
-  //           isFirst={i === 0}
-  //           setPodError={(x: string) => setPodError(x)}
-  //         />
-  //       );
-  //     });
-  //   };
-
   const renderStatusSection = (): JSX.Element => {
-    if (Object.keys(serviceVersionStatus).length === 0) {
+    if (Object.keys(appServiceStatus).length === 0) {
       return (
         <NoControllers>
           <Loading />
@@ -65,18 +39,17 @@ const StatusTab: React.FC = () => {
 
     return (
       <ServiceVersionContainer>
-        {Object.keys(serviceVersionStatus)
-          .map((serviceName, i) => {
-            const serviceStatus = serviceVersionStatus[serviceName];
+        {Object.keys(appServiceStatus)
+          .map((serviceName) => {
+            const serviceStatus = appServiceStatus[serviceName];
             const clientService = latestClientServices.find(
               (s) => s.name.value === serviceName
             );
             if (clientService) {
               return (
                 <ServiceStatus
-                  isLast={i === Object.keys(serviceVersionStatus).length - 1}
                   key={serviceName}
-                  serviceVersionStatusList={serviceStatus}
+                  serviceStatus={serviceStatus}
                   service={clientService}
                 />
               );
@@ -86,31 +59,6 @@ const StatusTab: React.FC = () => {
           .filter(valueExists)}
       </ServiceVersionContainer>
     );
-    // if (controllers?.length > 0) {
-    //   return (
-    //     <Wrapper>
-    //       <TabWrapper>{renderTabs()}</TabWrapper>
-    //       {renderLogs()}
-    //     </Wrapper>
-    //   );
-    // }
-
-    // if (currentChart?.chart?.metadata?.name === "job") {
-    //   return (
-    //     <NoControllers>
-    //       <i className="material-icons">category</i>
-    //       There are no jobs currently running.
-    //     </NoControllers>
-    //   );
-    // }
-
-    // return (
-    //   <NoControllers>
-    //     <i className="material-icons">category</i>
-    //     No objects to display. This might happen while your app is still
-    //     deploying.
-    //   </NoControllers>
-    // );
   };
 
   return <StyledStatusSection>{renderStatusSection()}</StyledStatusSection>;
@@ -118,22 +66,12 @@ const StatusTab: React.FC = () => {
 
 export default StatusTab;
 
-const TabWrapper = styled.div`
-  width: 35%;
-  min-width: 250px;
-  height: 100%;
-  overflow-y: auto;
-`;
-
 const StyledStatusSection = styled.div`
   padding: 0px;
   user-select: text;
-  overflow: hidden;
   width: 100%;
-  min-height: 400px;
-  height: calc(100vh - 400px);
+  height: 100%;
   font-size: 13px;
-  overflow: hidden;
   border-radius: 8px;
   animation: floatIn 0.3s;
   animation-timing-function: ease-out;
@@ -150,21 +88,6 @@ const StyledStatusSection = styled.div`
   }
 `;
 
-const FullScreen = styled.div`
-  position: absolute;
-  top: 0;
-  left: 0;
-  width: 100%;
-  height: 100%;
-  padding-top: 60px;
-`;
-
-const Wrapper = styled.div`
-  width: 100%;
-  height: 100%;
-  display: flex;
-`;
-
 const NoControllers = styled.div`
   padding-top: 20%;
   position: relative;
@@ -185,4 +108,5 @@ const ServiceVersionContainer = styled.div`
   display: flex;
   flex-direction: column;
   gap: 10px;
+  height: 100%;
 `;

+ 29 - 238
dashboard/src/main/home/app-dashboard/app-view/tabs/status/ServiceStatus.tsx

@@ -10,19 +10,15 @@ import job from "assets/job.png";
 import web from "assets/web.png";
 import worker from "assets/worker.png";
 
-import ServiceVersionInstanceStatus from "./ServiceVersionInstanceStatus";
+import ServiceVersionStatus from "./ServiceVersionStatus";
+import StatusTags from "./StatusTags";
 
 type Props = {
-  isLast: boolean;
   service: ClientService;
-  serviceVersionStatusList: ClientServiceStatus[];
+  serviceStatus: ClientServiceStatus;
 };
 
-const ServiceStatus: React.FC<Props> = ({
-  isLast,
-  serviceVersionStatusList,
-  service,
-}) => {
+const ServiceStatus: React.FC<Props> = ({ serviceStatus, service }) => {
   const [expanded, setExpanded] = useState<boolean>(false);
 
   const renderIcon = (service: ClientService): JSX.Element => {
@@ -39,38 +35,7 @@ const ServiceStatus: React.FC<Props> = ({
   };
 
   return (
-    <StyledResourceTab
-      isLast={isLast}
-      onClick={() => ({})}
-      roundAllCorners={true}
-    >
-      {/* <ResourceHeader
-        hasChildren={true}
-        expanded={expanded}
-        onClick={() => {
-          setExpanded(!expanded);
-        }}
-      >
-        <Info>
-          <DropdownIcon expanded={expanded}>
-            <i className="material-icons">arrow_right</i>
-          </DropdownIcon>
-          <ServiceNameTag>
-            {match(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} />
-            {service.name.value}
-          </ServiceNameTag>
-        </Info>
-        <Status>
-          test status
-          <StatusColor status={"running"} />
-        </Status>
-      </ResourceHeader> */}
+    <StyledResourceTab>
       <ServiceHeader
         showExpanded={expanded}
         onClick={() => {
@@ -87,53 +52,23 @@ const ServiceStatus: React.FC<Props> = ({
           {service.name.value}
         </ServiceTitle>
 
-        <Status>
-          test status
-          <StatusColor status={"running"} />
-        </Status>
+        <StatusTags
+          statusList={serviceStatus.versionStatusList.flatMap(
+            (v) => v.instanceStatusList
+          )}
+        />
       </ServiceHeader>
       {expanded && (
         <ExpandWrapper>
-          {serviceVersionStatusList.map((versionStatus) => {
+          {serviceStatus.versionStatusList.map((versionStatus) => {
             return (
-              <div key={versionStatus.revisionId}>
-                <ResourceHeader
-                  hasChildren={true}
-                  expanded={expanded}
-                  onClick={() => {
-                    setExpanded(!expanded);
-                  }}
-                >
-                  <Info>
-                    <DropdownIcon expanded={expanded}>
-                      <i className="material-icons">arrow_right</i>
-                    </DropdownIcon>
-                    <ReplicaSetName>
-                      <Bold>Version {versionStatus.revisionNumber}:</Bold>
-                    </ReplicaSetName>
-                  </Info>
-                  <Status>
-                    test status
-                    <StatusColor status={"running"} />
-                  </Status>
-                </ResourceHeader>
-                {/* <ReplicaSetContainer>
-                  <ReplicaSetName>
-                    <Bold>Version {versionStatus.revisionNumber}:</Bold>
-                  </ReplicaSetName>
-                </ReplicaSetContainer> */}
-                <ServiceVersionInstanceStatus
-                  serviceVersionStatus={versionStatus}
-                />
-              </div>
+              <ServiceVersionStatus
+                key={versionStatus.revisionId}
+                serviceVersionStatus={versionStatus}
+                serviceName={service.name.value}
+              />
             );
           })}
-          {/* <ConfirmOverlay
-            message="Are you sure you want to delete this pod?"
-            show={podPendingDelete}
-            onYes={() => handleDeletePod(podPendingDelete)}
-            onNo={() => setPodPendingDelete(null)}
-          /> */}
         </ExpandWrapper>
       )}
     </StyledResourceTab>
@@ -147,87 +82,20 @@ const StyledResourceTab = styled.div`
   margin-bottom: 2px;
   font-size: 13px;
   background: ${(props) => props.theme.fg};
-  border-bottom-left-radius: ${(props: {
-    isLast: boolean;
-    roundAllCorners: boolean;
-  }) => (props.isLast ? "10px" : "")};
-`;
-
-// const Tooltip = styled.div`
-//   position: absolute;
-//   right: 0px;
-//   top: 25px;
-//   white-space: nowrap;
-//   height: 18px;
-//   padding: 2px 5px;
-//   background: #383842dd;
-//   display: flex;
-//   align-items: center;
-//   justify-content: center;
-//   flex: 1;
-//   color: white;
-//   text-transform: none;
-//   font-size: 12px;
-//   outline: 1px solid #ffffff55;
-//   opacity: 0;
-//   animation: faded-in 0.2s 0.15s;
-//   animation-fill-mode: forwards;
-//   @keyframes faded-in {
-//     from {
-//       opacity: 0;
-//     }
-//     to {
-//       opacity: 1;
-//     }
-//   }
-// `;
-
-const ExpandWrapper = styled.div``;
-
-const ResourceHeader = styled.div`
-  width: 100%;
-  height: 50px;
-  display: flex;
-  font-size: 13px;
-  align-items: center;
-  justify-content: space-between;
-  user-select: none;
-  padding: 8px 18px;
-  padding-left: ${(props: { expanded: boolean; hasChildren: boolean }) =>
-    props.hasChildren ? "10px" : "22px"};
-  cursor: pointer;
-  background: ${(props: { expanded: boolean; hasChildren: boolean }) =>
-    props.expanded ? "#ffffff11" : ""};
-  :hover {
-    background: #ffffff18;
-
-    > i {
-      background: #ffffff22;
-    }
-  }
+  border-bottom-left-radius: 5px;
+  border-bottom-right-radius: 5px;
 `;
 
-const Info = styled.div`
+const ExpandWrapper = styled.div`
   display: flex;
-  flex-direction: row;
-  align-items: center;
-  width: 80%;
+  flex-direction: column;
   height: 100%;
 `;
 
-const Metadata = styled.div`
-  display: flex;
-  align-items: center;
-  position: relative;
-  max-width: ${(props: { hasStatus: boolean }) =>
-    props.hasStatus ? "calc(100% - 20px)" : "100%"};
-`;
-
-const Status = styled.div`
+export const Status = styled.div`
   display: flex;
   width; 20%;
   font-size: 12px;
-  text-transform: capitalize;
   justify-content: flex-end;
   align-items: center;
   color: #aaaabb;
@@ -238,96 +106,15 @@ const Status = styled.div`
   }
 `;
 
-const StatusColor = styled.div`
-  margin-left: 12px;
+export const StatusColor = styled.div<{ color: string }>`
+  margin-left: 7px;
   width: 8px;
   min-width: 8px;
   height: 8px;
-  background: ${(props: { status: string }) =>
-    props.status === "running" ||
-    props.status === "Ready" ||
-    props.status === "Completed"
-      ? "#4797ff"
-      : props.status === "failed" || props.status === "FailedValidation"
-      ? "#ed5f85"
-      : props.status === "completed"
-      ? "#00d12a"
-      : "#f5cb42"};
+  background: ${({ color }) => color};
   border-radius: 20px;
 `;
 
-const ResourceName = styled.div`
-  color: #ffffff;
-  max-width: 40%;
-  margin-left: ${(props: { showKindLabels: boolean }) =>
-    props.showKindLabels ? "10px" : ""};
-  text-transform: none;
-  white-space: nowrap;
-`;
-
-const IconWrapper = styled.div`
-  width: 25px;
-  height: 25px;
-  display: flex;
-  align-items: center;
-  justify-content: center;
-
-  > i {
-    font-size: 15px;
-    color: #ffffff;
-    margin-right: 14px;
-  }
-`;
-
-const DropdownIcon = styled.div`
-  > i {
-    margin-top: 2px;
-    margin-right: 11px;
-    font-size: 20px;
-    color: #ffffff66;
-    cursor: pointer;
-    border-radius: 20px;
-    background: ${(props: { expanded: boolean }) =>
-      props.expanded ? "#ffffff18" : ""};
-    transform: ${(props: { expanded: boolean }) =>
-      props.expanded ? "rotate(180deg)" : ""};
-    animation: ${(props: { expanded: boolean }) =>
-      props.expanded ? "quarterTurn 0.3s" : ""};
-    animation-fill-mode: forwards;
-
-    @keyframes quarterTurn {
-      from {
-        transform: rotate(0deg);
-      }
-      to {
-        transform: rotate(90deg);
-      }
-    }
-  }
-`;
-
-const ReplicaSetContainer = styled.div`
-  padding: 10px 5px;
-  display: flex;
-  overflow-wrap: anywhere;
-  justify-content: space-between;
-  border-top: 2px solid #ffffff11;
-`;
-
-const ReplicaSetName = styled.span`
-  padding-left: 10px;
-  overflow-wrap: anywhere;
-  max-width: calc(100% - 45px);
-  line-height: 1.5em;
-  color: #ffffff33;
-`;
-
-const Bold = styled.span`
-  font-weight: 500;
-  display: inline;
-  color: #ffffff;
-`;
-
 const ActionButton = styled.button`
   position: relative;
   border: none;
@@ -360,16 +147,20 @@ const ServiceHeader = styled.div<{
   padding: 20px;
   color: ${(props) => props.theme.text.primary};
   position: relative;
-  border-radius: 5px;
   background: ${(props) => props.theme.clickable.bg};
   border: 1px solid #494b4f;
+  border-bottom: ${(props) =>
+    props.showExpanded ? "none" : "1px solid #494b4f"};
   :hover {
     border: 1px solid #7a7b80;
+    border-bottom: ${(props) =>
+      props.showExpanded ? "none" : "1px solid #7a7b80"};
     ${ActionButton} {
       color: white;
     }
   }
 
+  border-radius: 5px;
   border-bottom-left-radius: ${(props) => (props.bordersRounded ? "" : "0")};
   border-bottom-right-radius: ${(props) => (props.bordersRounded ? "" : "0")};
 

+ 67 - 58
dashboard/src/main/home/app-dashboard/app-view/tabs/status/ServiceVersionInstanceStatus.tsx

@@ -1,45 +1,67 @@
 import React from "react";
+import dayjs from "dayjs";
 import styled from "styled-components";
 
 import Tooltip from "components/porter/Tooltip";
-import { type ClientServiceStatus } from "lib/hooks/useAppStatus";
+import {
+  statusColor,
+  type ClientServiceVersionInstanceStatus,
+} from "lib/hooks/useAppStatus";
 
 type Props = {
-  serviceVersionStatus: ClientServiceStatus;
+  serviceVersionInstanceStatus: ClientServiceVersionInstanceStatus;
+  isLast: boolean;
 };
 const ServiceVersionInstanceStatus: React.FC<Props> = ({
-  serviceVersionStatus,
+  serviceVersionInstanceStatus,
+  isLast,
 }) => {
   return (
-    <Tab selected={true} onClick={() => ({})}>
-      <Gutter>
-        <Rail />
-        <Circle />
-        <Rail lastTab={false} />
-      </Gutter>
-      <Tooltip
-        content={
-          <InstanceTooltip>
-            Version: {serviceVersionStatus.revisionNumber}
-            <Grey>Restart count: {serviceVersionStatus.restartCount}</Grey>
-            <Grey>Created on: 3 days</Grey>
-            {serviceVersionStatus.status === "failing" ? (
-              <FailedStatusContainer>
-                <Grey>
-                  Failure Reason: {serviceVersionStatus.crashLoopReason}
-                </Grey>
-              </FailedStatusContainer>
-            ) : null}
-          </InstanceTooltip>
-        }
-      >
-        <Name>Version: {serviceVersionStatus.revisionNumber}</Name>
-      </Tooltip>
-      <InstanceStatus>
-        <StatusColor status={"running"} />
-        {serviceVersionStatus.status}
-      </InstanceStatus>
-    </Tab>
+    <Tooltip
+      backgroundColor=""
+      content={
+        <InstanceTooltip>
+          {serviceVersionInstanceStatus.name}
+          <Grey>
+            Restart count: {serviceVersionInstanceStatus.restartCount}
+          </Grey>
+          <Grey>{`Created: ${dayjs(
+            serviceVersionInstanceStatus.creationTimestamp
+          ).format("MMM D, YYYY HH:mm:ss")}`}</Grey>
+        </InstanceTooltip>
+      }
+      containerWidth="100%"
+    >
+      <Tab selected={false} isLast={isLast} onClick={() => ({})}>
+        <GutterContainer>
+          <Gutter>
+            <Rail />
+            <Circle />
+            <Rail lastTab={isLast} />
+          </Gutter>
+        </GutterContainer>
+
+        <TooltipContainer>
+          <div
+            style={{
+              display: "flex",
+              flexDirection: "row",
+              width: "100%",
+              justifyContent: "space-between",
+            }}
+          >
+            <Name>
+              <Code>{serviceVersionInstanceStatus.name}</Code>
+            </Name>
+            <InstanceStatus>
+              <StatusColor
+                color={statusColor(serviceVersionInstanceStatus.status)}
+              />
+            </InstanceStatus>
+          </div>
+        </TooltipContainer>
+      </Tab>
+    </Tooltip>
   );
 };
 
@@ -50,11 +72,11 @@ const Grey = styled.div`
   color: #aaaabb;
 `;
 
-const FailedStatusContainer = styled.div`
+const GutterContainer = styled.div``;
+const TooltipContainer = styled.div`
+  display: flex;
   width: 100%;
-  border: 1px solid hsl(0deg, 100%, 30%);
-  padding: 5px;
-  margin-block: 5px;
+  height: 100%;
 `;
 
 const InstanceTooltip = styled.div`
@@ -89,26 +111,19 @@ const InstanceTooltip = styled.div`
   }
 `;
 
-const Tab = styled.div`
+const Tab = styled.div<{ selected: boolean; isLast: boolean }>`
   width: 100%;
-  height: 50px;
   position: relative;
   display: flex;
   align-items: center;
-  justify-content: space-between;
-  color: ${(props: { selected: boolean }) =>
-    props.selected ? "white" : "#ffffff66"};
   background: ${(props: { selected: boolean }) =>
     props.selected ? "#ffffff18" : ""};
   font-size: 13px;
   padding: 20px 19px 20px 42px;
   text-shadow: 0px 0px 8px none;
   overflow: visible;
-  cursor: pointer;
-  :hover {
-    color: white;
-    background: #ffffff18;
-  }
+  border: 1px solid #494b4f;
+  border-bottom: ${(props) => (props.isLast ? "1px solid #494b4f" : "none")};
 `;
 
 const Rail = styled.div`
@@ -143,7 +158,6 @@ const InstanceStatus = styled.div`
   font-size: 12px;
   text-transform: capitalize;
   margin-left: 5px;
-  justify-content: flex-end;
   align-items: center;
   font-family: "Work Sans", sans-serif;
   color: #aaaabb;
@@ -168,20 +182,15 @@ const Name = styled.div`
   -webkit-line-clamp: 2;
 `;
 
-const StatusColor = styled.div`
+const StatusColor = styled.div<{ color: string }>`
   margin-left: 12px;
   width: 8px;
   min-width: 8px;
   height: 8px;
-  background: ${(props: { status: string }) =>
-    props.status === "running" ||
-    props.status === "Ready" ||
-    props.status === "Completed"
-      ? "#4797ff"
-      : props.status === "failed" || props.status === "FailedValidation"
-      ? "#ed5f85"
-      : props.status === "completed"
-      ? "#00d12a"
-      : "#f5cb42"};
+  background: ${({ color }) => color};
   border-radius: 20px;
 `;
+
+const Code = styled.span`
+  font-family: monospace;
+`;

+ 178 - 3
dashboard/src/main/home/app-dashboard/app-view/tabs/status/ServiceVersionStatus.tsx

@@ -1,7 +1,182 @@
-import React from "react";
+import React, { useMemo, useState } from "react";
+import styled from "styled-components";
 
-const ServiceVersionStatus: React.FC = () => {
-  return <div>{/* Your component content goes here */}</div>;
+import Link from "components/porter/Link";
+import Tag from "components/porter/Tag";
+import { type ClientServiceVersionStatus } from "lib/hooks/useAppStatus";
+import {
+  isClientRevisionNotification,
+  isClientServiceNotification,
+} from "lib/porter-apps/notification";
+
+import alert from "assets/alert-warning.svg";
+
+import { useLatestRevision } from "../../LatestRevisionContext";
+import ServiceVersionInstanceStatus from "./ServiceVersionInstanceStatus";
+import StatusTags from "./StatusTags";
+
+type Props = {
+  serviceVersionStatus: ClientServiceVersionStatus;
+  serviceName: string;
+};
+const ServiceVersionStatus: React.FC<Props> = ({
+  serviceVersionStatus,
+  serviceName,
+}) => {
+  const [expanded, setExpanded] = useState<boolean>(false);
+
+  const { tabUrlGenerator, latestClientNotifications } = useLatestRevision();
+
+  const notificationsExistForServiceVersion = useMemo(() => {
+    return (
+      latestClientNotifications
+        .filter(isClientRevisionNotification)
+        .some((n) => n.appRevisionId === serviceVersionStatus.revisionId) ||
+      latestClientNotifications
+        .filter(isClientServiceNotification)
+        .some(
+          (n) =>
+            n.appRevisionId === serviceVersionStatus.revisionId &&
+            n.service.name.value === serviceName
+        )
+    );
+  }, [
+    JSON.stringify(latestClientNotifications),
+    JSON.stringify(serviceVersionStatus),
+    serviceName,
+  ]);
+
+  return (
+    <StyledServiceVersionStatus>
+      <ResourceHeader
+        expanded={expanded}
+        onClick={() => {
+          setExpanded(!expanded);
+        }}
+      >
+        <Info>
+          <DropdownIcon expanded={expanded}>
+            <i className="material-icons">arrow_right</i>
+          </DropdownIcon>
+          <ReplicaSetName>
+            <Bold>Version {serviceVersionStatus.revisionNumber}</Bold>
+          </ReplicaSetName>
+        </Info>
+        <div>
+          <StatusTags statusList={serviceVersionStatus.instanceStatusList} />
+
+          {notificationsExistForServiceVersion && (
+            <Tag borderColor="#FFBF00">
+              <Link
+                to={tabUrlGenerator({
+                  tab: "notifications",
+                })}
+                color={"#FFBF00"}
+              >
+                <TagIcon src={alert} />
+                Notifications
+              </Link>
+            </Tag>
+          )}
+        </div>
+      </ResourceHeader>
+      {expanded && (
+        <ServiceVersionInstanceStatusContainer>
+          {serviceVersionStatus.instanceStatusList.map((instanceStatus, i) => (
+            <ServiceVersionInstanceStatus
+              key={instanceStatus.name}
+              serviceVersionInstanceStatus={instanceStatus}
+              isLast={i === serviceVersionStatus.instanceStatusList.length - 1}
+            />
+          ))}
+        </ServiceVersionInstanceStatusContainer>
+      )}
+    </StyledServiceVersionStatus>
+  );
 };
 
 export default ServiceVersionStatus;
+
+const StyledServiceVersionStatus = styled.div``;
+
+const ServiceVersionInstanceStatusContainer = styled.div`
+  display: flex;
+  flex-direction: column;
+  height: 100%;
+`;
+
+const ResourceHeader = styled.div<{ expanded: boolean }>`
+  width: 100%;
+  height: 50px;
+  display: flex;
+  font-size: 13px;
+  align-items: center;
+  justify-content: space-between;
+  user-select: none;
+  padding: 8px 18px;
+  cursor: pointer;
+  border: 1px solid #494b4f;
+  border-bottom: ${(props) => (props.expanded ? "none" : "1px solid #494b4f")};
+
+  :hover {
+    background: #ffffff18;
+
+    > i {
+      background: #ffffff22;
+    }
+  }
+`;
+
+const Info = styled.div`
+  display: flex;
+  flex-direction: row;
+  align-items: center;
+  width: 80%;
+  height: 100%;
+`;
+
+const DropdownIcon = styled.div`
+  > i {
+    margin-top: 2px;
+    margin-right: 11px;
+    font-size: 20px;
+    color: #ffffff66;
+    cursor: pointer;
+    border-radius: 20px;
+    background: ${(props: { expanded: boolean }) =>
+      props.expanded ? "#ffffff18" : ""};
+    transform: ${(props: { expanded: boolean }) =>
+      props.expanded ? "rotate(180deg)" : ""};
+    animation: ${(props: { expanded: boolean }) =>
+      props.expanded ? "quarterTurn 0.3s" : ""};
+    animation-fill-mode: forwards;
+
+    @keyframes quarterTurn {
+      from {
+        transform: rotate(0deg);
+      }
+      to {
+        transform: rotate(90deg);
+      }
+    }
+  }
+`;
+
+const ReplicaSetName = styled.span`
+  padding-left: 10px;
+  overflow-wrap: anywhere;
+  max-width: calc(100% - 45px);
+  line-height: 1.5em;
+  color: #ffffff33;
+`;
+
+const Bold = styled.span`
+  font-weight: 500;
+  display: inline;
+  color: #ffffff;
+`;
+
+const TagIcon = styled.img`
+  height: 12px;
+  margin-right: 3px;
+`;

+ 158 - 0
dashboard/src/main/home/app-dashboard/app-view/tabs/status/StatusTags.tsx

@@ -0,0 +1,158 @@
+import React, { useMemo } from "react";
+import pluralize from "pluralize";
+import styled from "styled-components";
+
+import Spacer from "components/porter/Spacer";
+import Tag from "components/porter/Tag";
+import Text from "components/porter/Text";
+import Tooltip from "components/porter/Tooltip";
+import {
+  statusColor,
+  type ServiceStatusDescriptor,
+} from "lib/hooks/useAppStatus";
+
+import { Status, StatusColor } from "./ServiceStatus";
+
+type StatusType = {
+  status: ServiceStatusDescriptor;
+};
+
+type Props<T extends StatusType[]> = {
+  statusList: T;
+};
+const StatusTags: React.FC<Props<StatusType[]>> = ({ statusList }) => {
+  const statusSummary = useMemo(() => {
+    return [
+      statusList.filter((i) => i.status === "running").length,
+      statusList.filter((i) => i.status === "pending").length,
+      statusList.filter((i) => i.status === "failing").length,
+    ];
+  }, [JSON.stringify(statusList)]);
+
+  return (
+    <Status>
+      <Tooltip
+        content={
+          <TooltipContent>
+            <Grey>{`${statusSummary[0]} ${pluralize(
+              "instance",
+              statusSummary[0]
+            )} ${pluralize("is", statusSummary[0])} currently running`}</Grey>
+          </TooltipContent>
+        }
+        backgroundColor=""
+      >
+        <Tag
+          hoverable={false}
+          backgroundColor={
+            statusSummary[0] ? statusColor("running") : undefined
+          }
+        >
+          <InnerTag>
+            <Text color={statusSummary[0] ? "white" : "helper"}>
+              {statusSummary[0]}
+            </Text>
+            <StatusColor color={statusSummary[0] ? "white" : "#ffffff22"} />
+          </InnerTag>
+        </Tag>
+      </Tooltip>
+      <Spacer inline x={0.5} />
+      <Tooltip
+        content={
+          <TooltipContent>
+            <Grey>{`${statusSummary[1]} ${pluralize(
+              "instance",
+              statusSummary[1]
+            )} ${pluralize("is", statusSummary[1])} currently pending`}</Grey>
+          </TooltipContent>
+        }
+        backgroundColor=""
+      >
+        <Tag
+          hoverable={false}
+          backgroundColor={
+            statusSummary[1] ? statusColor("pending") : undefined
+          }
+        >
+          <InnerTag>
+            <Text color={statusSummary[1] ? "white" : "helper"}>
+              {statusSummary[1]}
+            </Text>
+            <StatusColor color={statusSummary[1] ? "white" : "#ffffff22"} />
+          </InnerTag>
+        </Tag>
+      </Tooltip>
+      <Spacer inline x={0.5} />
+      <Tooltip
+        content={
+          <TooltipContent>
+            <Grey>{`${statusSummary[2]} ${pluralize(
+              "instance",
+              statusSummary[2]
+            )} ${pluralize("is", statusSummary[2])} currently failing`}</Grey>
+          </TooltipContent>
+        }
+        backgroundColor=""
+      >
+        <Tag
+          hoverable={false}
+          backgroundColor={
+            statusSummary[2] ? statusColor("failing") : undefined
+          }
+        >
+          <InnerTag>
+            <Text color={statusSummary[2] ? "white" : "helper"}>
+              {statusSummary[2]}
+            </Text>
+            <StatusColor color={statusSummary[2] ? "white" : "#ffffff22"} />
+          </InnerTag>
+        </Tag>
+      </Tooltip>
+    </Status>
+  );
+};
+
+export default StatusTags;
+
+const InnerTag = styled.div`
+  display: flex;
+  align-items: center;
+  width: 25px;
+`;
+
+const TooltipContent = styled.div`
+  position: absolute;
+  left: 35px;
+  word-wrap: break-word;
+  top: 38px;
+  min-height: 18px;
+  max-width: calc(100% - 75px);
+  padding: 5px 7px;
+  background: #272731;
+  z-index: 999;
+  display: flex;
+  flex-direction: column;
+  justify-content: center;
+  flex: 1;
+  color: white;
+  text-transform: none;
+  font-size: 12px;
+  font-family: "Work Sans", sans-serif;
+  outline: 1px solid #ffffff55;
+  opacity: 0;
+  animation: faded-in 0.2s 0.15s;
+  animation-fill-mode: forwards;
+  @keyframes faded-in {
+    from {
+      opacity: 0;
+    }
+    to {
+      opacity: 1;
+    }
+  }
+`;
+
+const Grey = styled.div`
+  margin-top: 5px;
+  color: #aaaabb;
+`;

+ 2 - 2
dashboard/src/main/home/app-dashboard/validate-apply/services-settings/ServiceContainer.tsx

@@ -6,7 +6,7 @@ import styled, { keyframes } from "styled-components";
 import { match } from "ts-pattern";
 
 import Spacer from "components/porter/Spacer";
-import { type ClientServiceStatus } from "lib/hooks/useAppStatus";
+import { type ClientServiceVersionInstanceStatus } from "lib/hooks/useAppStatus";
 import { type PorterAppFormData } from "lib/porter-apps";
 import { type ClientService } from "lib/porter-apps/services";
 
@@ -28,7 +28,7 @@ type ServiceProps = {
     "app.services" | "app.predeploy"
   >;
   remove: (index: number) => void;
-  status?: ClientServiceStatus[];
+  status?: ClientServiceVersionInstanceStatus[];
   maxCPU: number;
   maxRAM: number;
   clusterContainsGPUNodes: boolean;

+ 2 - 2
dashboard/src/main/home/app-dashboard/validate-apply/services-settings/ServiceList.tsx

@@ -16,7 +16,7 @@ import Modal from "components/porter/Modal";
 import Select from "components/porter/Select";
 import Spacer from "components/porter/Spacer";
 import Text from "components/porter/Text";
-import { type ClientServiceStatus } from "lib/hooks/useAppStatus";
+import { type ClientServiceVersionInstanceStatus } from "lib/hooks/useAppStatus";
 import { type PorterAppFormData } from "lib/porter-apps";
 import {
   defaultSerialized,
@@ -50,7 +50,7 @@ type ServiceListProps = {
   isPredeploy?: boolean;
   existingServiceNames?: string[];
   fieldArrayName: "app.services" | "app.predeploy";
-  serviceVersionStatus?: Record<string, ClientServiceStatus[]>;
+  serviceVersionStatus?: Record<string, ClientServiceVersionInstanceStatus[]>;
   internalNetworkingDetails?: {
     namespace: string;
     appName: string;

+ 3 - 3
dashboard/src/main/home/app-dashboard/validate-apply/services-settings/ServiceStatusFooter.tsx

@@ -10,7 +10,7 @@ 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 ClientServiceStatus } from "lib/hooks/useAppStatus";
+import { type ClientServiceVersionInstanceStatus } from "lib/hooks/useAppStatus";
 import { isClientServiceNotification } from "lib/porter-apps/notification";
 
 import alert from "assets/alert-warning.svg";
@@ -20,7 +20,7 @@ import TriggerJobButton from "../jobs/TriggerJobButton";
 
 type ServiceStatusFooterProps = {
   serviceName: string;
-  status: ClientServiceStatus[];
+  status: ClientServiceVersionInstanceStatus[];
   isJob: boolean;
 };
 const ServiceStatusFooter: React.FC<ServiceStatusFooterProps> = ({
@@ -110,7 +110,7 @@ const ServiceStatusFooter: React.FC<ServiceStatusFooterProps> = ({
                       </>
                     );
                   })
-                  .with({ status: "spinningDown" }, (vs) => {
+                  .with({ status: "pending" }, (vs) => {
                     return (
                       <Running>
                         <StatusDot color="#FFA500" />

+ 4 - 1
internal/porter_app/status.go

@@ -56,6 +56,7 @@ type InstanceStatus struct {
 	Status            InstanceStatusDescriptor `json:"status"`
 	RestartCount      int                      `json:"restart_count"`
 	CreationTimestamp time.Time                `json:"creation_timestamp"`
+	Name              string                   `json:"name"`
 }
 
 // GetServiceStatusInput is the input type for GetServiceStatus
@@ -199,7 +200,9 @@ func instanceStatusFromPod(ctx context.Context, inp instanceStatusFromPodInput)
 
 	telemetry.WithAttributes(span, telemetry.AttributeKV{Key: "pod-name", Value: inp.Pod.Name})
 
-	instanceStatus := InstanceStatus{}
+	instanceStatus := InstanceStatus{
+		Name: inp.Pod.Name,
+	}
 
 	// find the container running the app code. Note that this is conditioned on the fact that
 	// in our worker/web/job charts, there is one container created with this name during the deployment