diff --git a/dashboard/src/components/porter/Tooltip.tsx b/dashboard/src/components/porter/Tooltip.tsx index 5dcb44337f..808a0d57d9 100644 --- a/dashboard/src/components/porter/Tooltip.tsx +++ b/dashboard/src/components/porter/Tooltip.tsx @@ -2,34 +2,47 @@ 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 = ({ children, content, position = "top", hidden = false, - width, + 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 ( - + {isVisible && ( - {content} + + {content} + )} {children} @@ -38,12 +51,14 @@ const Tooltip: React.FC = ({ 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 }>` +const TooltipContent = styled.div<{ position: string; width?: string }>` color: #fff; padding: 10px; border-radius: 5px; diff --git a/dashboard/src/lib/hooks/useAppStatus.ts b/dashboard/src/lib/hooks/useAppStatus.ts index 1ef4a06905..504ff66a0c 100644 --- a/dashboard/src/lib/hooks/useAppStatus.ts +++ b/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 { type ClientService } from "lib/porter-apps/services"; @@ -10,14 +10,35 @@ import { useWebsockets, type NewWebsocketOptions, } from "shared/hooks/useWebsockets"; -import { valueExists } from "shared/util"; + +export type AppServiceStatus = Record; + +export type ServiceStatusDescriptor = + | "running" + | "pending" + | "failing" + | "unknown"; export type ClientServiceStatus = { - status: "running" | "spinningDown" | "failing"; - message: string; - crashLoopReason: string; - restartCount?: number; + status: ServiceStatusDescriptor; + serviceName: string; + versionStatusList: ClientServiceVersionStatus[]; +}; + +export type ClientServiceVersionStatus = { + status: ServiceStatusDescriptor; + revisionId: string; + revisionNumber: number; + instanceStatusList: ClientServiceVersionInstanceStatus[]; +}; + +export type ClientServiceVersionInstanceStatus = { + status: ServiceStatusDescriptor; revisionId: string; + crashLoopReason: string; + restartCount: number; + name: string; + creationTimestamp: string; }; const serviceStatusValidator = z.object({ @@ -28,13 +49,10 @@ const serviceStatusValidator = z.object({ revision_number: z.number(), instance_status_list: z.array( z.object({ - status: z.union([ - z.literal("PENDING"), - z.literal("RUNNING"), - z.literal("FAILED"), - ]), + status: z.enum(["PENDING", "RUNNING", "FAILED"]), restart_count: z.number(), creation_timestamp: z.string(), + name: z.string(), }) ), }) @@ -56,7 +74,9 @@ export const useAppStatus = ({ deploymentTargetId: string; appName: string; kind?: string; -}): { serviceVersionStatus: Record } => { +}): { + appServiceStatus: AppServiceStatus; +} => { const [serviceStatusMap, setServiceStatusMap] = useState< Record >({}); @@ -130,75 +150,100 @@ 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, - revisionId: revisionStatus.revision_id, + .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 = + { + revisionId: revisionStatus.revision_id, + status, + crashLoopReason: "", + restartCount: instanceStatus.restart_count, + name: instanceStatus.name, + creationTimestamp: instanceStatus.creation_timestamp, + }; + + 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, - revisionId: revisionStatus.revision_id, - }); + 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, - revisionId: revisionStatus.revision_id, - }); + 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(); +}; diff --git a/dashboard/src/main/home/app-dashboard/app-view/AppDataContainer.tsx b/dashboard/src/main/home/app-dashboard/app-view/AppDataContainer.tsx index 6744c2cd24..5ac1b3634a 100644 --- a/dashboard/src/main/home/app-dashboard/app-view/AppDataContainer.tsx +++ b/dashboard/src/main/home/app-dashboard/app-view/AppDataContainer.tsx @@ -57,6 +57,7 @@ import MetricsTab from "./tabs/MetricsTab"; import Notifications from "./tabs/Notifications"; import Overview from "./tabs/Overview"; import Settings from "./tabs/Settings"; +import StatusTab from "./tabs/StatusTab"; // commented out tabs are not yet implemented // will be included as support is available based on data from app revisions rather than helm releases @@ -75,6 +76,7 @@ const validTabs = [ "helm-values", "job-history", "notifications", + "status", ] as const; const DEFAULT_TAB = "activity"; type ValidTab = (typeof validTabs)[number]; @@ -453,39 +455,74 @@ const AppDataContainer: React.FC = ({ tabParam }) => { const tabs = useMemo(() => { const numNotifications = latestClientNotifications.length; - const base = [ - { - label: `Notifications`, - value: "notifications", - sibling: - numNotifications > 0 ? ( - - - -
- {numNotifications} -
- -
- ) : undefined, - }, - { label: "Activity", value: "activity" }, - { label: "Overview", value: "overview" }, - { label: "Logs", value: "logs" }, - { label: "Metrics", value: "metrics" }, - { label: "Environment", value: "environment" }, - ]; + const base = currentProject?.beta_features_enabled + ? [ + { + label: `Notifications`, + value: "notifications", + sibling: + numNotifications > 0 ? ( + + + +
+ {numNotifications} +
+ +
+ ) : undefined, + }, + { label: "Activity", value: "activity" }, + { label: "Status", value: "status" }, + { label: "Logs", value: "logs" }, + { label: "Metrics", value: "metrics" }, + { label: "Services", value: "overview" }, + { label: "Environment", value: "environment" }, + ] + : [ + { + label: `Notifications`, + value: "notifications", + sibling: + numNotifications > 0 ? ( + + + +
+ {numNotifications} +
+ +
+ ) : undefined, + }, + { label: "Activity", value: "activity" }, + { label: "Overview", value: "overview" }, + { label: "Logs", value: "logs" }, + { label: "Metrics", value: "metrics" }, + { label: "Environment", value: "environment" }, + ]; if (deploymentTarget.is_preview) { return base; @@ -635,6 +672,7 @@ const AppDataContainer: React.FC = ({ tabParam }) => { /> )) .with("notifications", () => ) + .with("status", () => ) .otherwise(() => null)} diff --git a/dashboard/src/main/home/app-dashboard/app-view/tabs/Overview.tsx b/dashboard/src/main/home/app-dashboard/app-view/tabs/Overview.tsx index 05490ea65a..28d6e12f49 100644 --- a/dashboard/src/main/home/app-dashboard/app-view/tabs/Overview.tsx +++ b/dashboard/src/main/home/app-dashboard/app-view/tabs/Overview.tsx @@ -36,7 +36,7 @@ const Overview: React.FC = ({ buttonStatus }) => { clusterId, }); - const { serviceVersionStatus } = useAppStatus({ + const { appServiceStatus } = useAppStatus({ projectId, clusterId, services: latestClientServices.filter( @@ -63,7 +63,7 @@ const Overview: React.FC = ({ buttonStatus }) => { addNewText={"Add a new service"} fieldArrayName={"app.services"} existingServiceNames={latestProto.serviceList.map((s) => s.name)} - serviceVersionStatus={serviceVersionStatus} + appServiceStatus={appServiceStatus} internalNetworkingDetails={{ namespace: deploymentTarget.namespace, appName: porterApp.name, diff --git a/dashboard/src/main/home/app-dashboard/app-view/tabs/StatusTab.tsx b/dashboard/src/main/home/app-dashboard/app-view/tabs/StatusTab.tsx new file mode 100644 index 0000000000..64ba446f9c --- /dev/null +++ b/dashboard/src/main/home/app-dashboard/app-view/tabs/StatusTab.tsx @@ -0,0 +1,113 @@ +import React from "react"; +import styled from "styled-components"; + +import Loading from "components/Loading"; +import { useAppStatus } from "lib/hooks/useAppStatus"; + +import { valueExists } from "shared/util"; + +import { useLatestRevision } from "../LatestRevisionContext"; +import ServiceStatus from "./status/ServiceStatus"; + +const StatusTab: React.FC = () => { + const { + projectId, + clusterId, + latestClientServices, + deploymentTarget, + appName, + } = useLatestRevision(); + + const { appServiceStatus } = useAppStatus({ + projectId, + clusterId, + services: latestClientServices.filter( + (s) => s.config.type === "web" || s.config.type === "worker" + ), + deploymentTargetId: deploymentTarget.id, + appName, + }); + + const renderStatusSection = (): JSX.Element => { + if (Object.keys(appServiceStatus).length === 0) { + return ( + + + + ); + } + + return ( + + {Object.keys(appServiceStatus) + .sort() + .map((serviceName) => { + const serviceStatus = appServiceStatus[serviceName]; + const clientService = latestClientServices.find( + (s) => s.name.value === serviceName + ); + if (clientService) { + return ( + + ); + } + return null; + }) + .filter(valueExists)} + + ); + }; + + return {renderStatusSection()}; +}; + +export default StatusTab; + +const StyledStatusSection = styled.div` + padding: 0px; + user-select: text; + width: 100%; + height: 100%; + font-size: 13px; + border-radius: 8px; + animation: floatIn 0.3s; + animation-timing-function: ease-out; + animation-fill-mode: forwards; + @keyframes floatIn { + from { + opacity: 0; + transform: translateY(10px); + } + to { + opacity: 1; + transform: translateY(0px); + } + } +`; + +const NoControllers = styled.div` + padding-top: 20%; + position: relative; + width: 100%; + display: flex; + justify-content: center; + align-items: center; + color: #ffffff44; + font-size: 14px; + + > i { + font-size: 18px; + margin-right: 12px; + } +`; + +const ServiceVersionContainer = styled.div` + display: flex; + flex-direction: column; + gap: 10px; + height: 100%; +`; diff --git a/dashboard/src/main/home/app-dashboard/app-view/tabs/status/ServiceStatus.tsx b/dashboard/src/main/home/app-dashboard/app-view/tabs/status/ServiceStatus.tsx new file mode 100644 index 0000000000..fa891ae868 --- /dev/null +++ b/dashboard/src/main/home/app-dashboard/app-view/tabs/status/ServiceStatus.tsx @@ -0,0 +1,182 @@ +import React, { useState } from "react"; +import styled from "styled-components"; + +import Icon from "components/porter/Icon"; +import Spacer from "components/porter/Spacer"; +import { type ClientServiceStatus } from "lib/hooks/useAppStatus"; +import { type ClientService } from "lib/porter-apps/services"; + +import job from "assets/job.png"; +import web from "assets/web.png"; +import worker from "assets/worker.png"; + +import ServiceVersionStatus from "./ServiceVersionStatus"; +import StatusTags from "./StatusTags"; + +type Props = { + service: ClientService; + serviceStatus: ClientServiceStatus; +}; + +const ServiceStatus: React.FC = ({ serviceStatus, service }) => { + const [expanded, setExpanded] = useState(false); + + const renderIcon = (service: ClientService): JSX.Element => { + switch (service.config.type) { + case "web": + return ; + case "worker": + return ; + case "job": + return ; + case "predeploy": + return ; + case "initdeploy": + return ; + } + }; + + return ( + + { + setExpanded(!expanded); + }} + bordersRounded={!expanded} + > + + + arrow_drop_down + + {renderIcon(service)} + + {service.name.value} + + + v.instanceStatusList + )} + /> + + {expanded && ( + + {serviceStatus.versionStatusList.map((versionStatus) => { + return ( + + ); + })} + + )} + + ); +}; + +export default ServiceStatus; + +const StyledResourceTab = styled.div` + width: 100%; + margin-bottom: 2px; + font-size: 13px; + background: ${(props) => props.theme.fg}; + border-bottom-left-radius: 5px; + border-bottom-right-radius: 5px; +`; + +const ExpandWrapper = styled.div` + display: flex; + flex-direction: column; + height: 100%; +`; + +export const Status = styled.div` + display: flex; + width; 20%; + font-size: 12px; + justify-content: flex-end; + align-items: center; + color: #aaaabb; + animation: fadeIn 0.5s; + @keyframes fadeIn { + from { opacity: 0 } + to { opacity: 1 } + } +`; + +export const StatusColor = styled.div<{ color: string }>` + margin-left: 7px; + width: 8px; + min-width: 8px; + height: 8px; + background: ${({ color }) => color}; + border-radius: 20px; +`; + +const ActionButton = styled.button` + position: relative; + border: none; + background: none; + color: white; + padding: 5px; + display: flex; + justify-content: center; + align-items: center; + border-radius: 50%; + cursor: pointer; + color: #aaaabb; + + > span { + font-size: 20px; + } + margin-right: 5px; +`; + +const ServiceHeader = styled.div<{ + showExpanded?: boolean; + bordersRounded?: boolean; +}>` + flex-direction: row; + display: flex; + height: 60px; + font-size: 18px; + justify-content: space-between; + cursor: pointer; + padding: 20px; + color: ${(props) => props.theme.text.primary}; + position: relative; + 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")}; + + .dropdown { + font-size: 30px; + cursor: pointer; + border-radius: 20px; + margin-left: -10px; + transform: ${(props: { showExpanded?: boolean }) => + props.showExpanded ? "" : "rotate(-90deg)"}; + } +`; + +const ServiceTitle = styled.div` + display: flex; + align-items: center; +`; diff --git a/dashboard/src/main/home/app-dashboard/app-view/tabs/status/ServiceVersionInstanceStatus.tsx b/dashboard/src/main/home/app-dashboard/app-view/tabs/status/ServiceVersionInstanceStatus.tsx new file mode 100644 index 0000000000..533be729de --- /dev/null +++ b/dashboard/src/main/home/app-dashboard/app-view/tabs/status/ServiceVersionInstanceStatus.tsx @@ -0,0 +1,165 @@ +import React from "react"; +import dayjs from "dayjs"; +import styled from "styled-components"; + +import Tooltip from "components/porter/Tooltip"; +import { + statusColor, + type ClientServiceVersionInstanceStatus, +} from "lib/hooks/useAppStatus"; + +type Props = { + serviceVersionInstanceStatus: ClientServiceVersionInstanceStatus; + isLast: boolean; +}; +const ServiceVersionInstanceStatus: React.FC = ({ + serviceVersionInstanceStatus, + isLast, +}) => { + return ( + + {serviceVersionInstanceStatus.name} + + Restart count: {serviceVersionInstanceStatus.restartCount} + + {`Created: ${dayjs( + serviceVersionInstanceStatus.creationTimestamp + ).format("MMM D, YYYY HH:mm:ss Z")}`} + + } + containerWidth="100%" + tooltipContentWidth="300px" + > + ({})}> + + + + + + + + + +
+ + {serviceVersionInstanceStatus.name} + + + + +
+
+
+
+ ); +}; + +export default ServiceVersionInstanceStatus; + +const Grey = styled.div` + margin-top: 5px; + color: #aaaabb; +`; + +const GutterContainer = styled.div``; +const TooltipContainer = styled.div` + display: flex; + width: 100%; + height: 100%; +`; + +const Tab = styled.div<{ selected: boolean; isLast: boolean }>` + width: 100%; + position: relative; + display: flex; + align-items: center; + background: ${(props: { selected: boolean }) => + props.selected ? "#ffffff18" : ""}; + font-size: 13px; + padding: 20px 19px 20px 42px; + text-shadow: 0px 0px 8px none; + overflow: visible; + border: 1px solid #494b4f; + border-bottom: ${(props) => (props.isLast ? "1px solid #494b4f" : "none")}; +`; + +const Rail = styled.div` + width: 2px; + background: ${(props: { lastTab?: boolean }) => + props.lastTab ? "" : "#52545D"}; + height: 50%; +`; + +const Circle = styled.div` + min-width: 10px; + min-height: 2px; + margin-bottom: -2px; + margin-left: 8px; + background: #52545d; +`; + +const Gutter = styled.div` + position: absolute; + top: 0px; + left: 10px; + height: 100%; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + overflow: visible; +`; + +const InstanceStatus = styled.div` + display: flex; + font-size: 12px; + text-transform: capitalize; + margin-left: 5px; + align-items: center; + font-family: "Work Sans", sans-serif; + color: #aaaabb; + animation: fadeIn 0.5s; + @keyframes fadeIn { + from { + opacity: 0; + } + to { + opacity: 1; + } + } +`; + +const Name = styled.div` + overflow: hidden; + text-overflow: ellipsis; + line-height: 1.5em; + display: -webkit-box; + overflow-wrap: anywhere; + -webkit-box-orient: vertical; + -webkit-line-clamp: 2; +`; + +const StatusColor = styled.div<{ color: string }>` + margin-left: 12px; + width: 8px; + min-width: 8px; + height: 8px; + background: ${({ color }) => color}; + border-radius: 20px; +`; + +const Code = styled.span` + font-family: monospace; +`; diff --git a/dashboard/src/main/home/app-dashboard/app-view/tabs/status/ServiceVersionStatus.tsx b/dashboard/src/main/home/app-dashboard/app-view/tabs/status/ServiceVersionStatus.tsx new file mode 100644 index 0000000000..dd430f96dd --- /dev/null +++ b/dashboard/src/main/home/app-dashboard/app-view/tabs/status/ServiceVersionStatus.tsx @@ -0,0 +1,182 @@ +import React, { useMemo, useState } from "react"; +import styled from "styled-components"; + +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 = ({ + serviceVersionStatus, + serviceName, +}) => { + const [expanded, setExpanded] = useState(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 ( + + { + setExpanded(!expanded); + }} + > + + + arrow_right + + + Version {serviceVersionStatus.revisionNumber} + + +
+ + + {notificationsExistForServiceVersion && ( + + + + Notifications + + + )} +
+
+ {expanded && ( + + {serviceVersionStatus.instanceStatusList.map((instanceStatus, i) => ( + + ))} + + )} +
+ ); +}; + +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; +`; diff --git a/dashboard/src/main/home/app-dashboard/app-view/tabs/status/StatusTags.tsx b/dashboard/src/main/home/app-dashboard/app-view/tabs/status/StatusTags.tsx new file mode 100644 index 0000000000..2db3323fef --- /dev/null +++ b/dashboard/src/main/home/app-dashboard/app-view/tabs/status/StatusTags.tsx @@ -0,0 +1,117 @@ +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 = { + statusList: T; +}; +const StatusTags: React.FC> = ({ 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 ( + + + {`${statusSummary[0]} ${pluralize( + "instance", + statusSummary[0] + )} ${pluralize("is", statusSummary[0])} currently running`} + + } + backgroundColor="" + > + + + + {statusSummary[0]} + + + + + + + {`${statusSummary[1]} ${pluralize( + "instance", + statusSummary[1] + )} ${pluralize("is", statusSummary[1])} currently pending`} + } + backgroundColor="" + > + + + + {statusSummary[1]} + + + + + + + {`${statusSummary[2]} ${pluralize( + "instance", + statusSummary[2] + )} ${pluralize("is", statusSummary[2])} currently failing`} + } + backgroundColor="" + > + + + + {statusSummary[2]} + + + + + + + ); +}; + +export default StatusTags; + +const InnerTag = styled.div` + display: flex; + align-items: center; + width: 25px; +`; diff --git a/dashboard/src/main/home/app-dashboard/validate-apply/services-settings/ServiceContainer.tsx b/dashboard/src/main/home/app-dashboard/validate-apply/services-settings/ServiceContainer.tsx index 6add174305..4f140bd880 100644 --- a/dashboard/src/main/home/app-dashboard/validate-apply/services-settings/ServiceContainer.tsx +++ b/dashboard/src/main/home/app-dashboard/validate-apply/services-settings/ServiceContainer.tsx @@ -33,7 +33,7 @@ type ServiceProps = { "app.services" | "app.predeploy" >; remove: (index: number) => void; - status?: ClientServiceStatus[]; + status?: ClientServiceStatus; internalNetworkingDetails: { namespace: string; appName: string; @@ -68,6 +68,7 @@ const ServiceContainer: React.FC = ({ .with({ config: { type: "predeploy" } }, (svc) => ( )) + .with({ config: { type: "initdeploy" } }, () => <>) .exhaustive(); }; @@ -81,6 +82,8 @@ const ServiceContainer: React.FC = ({ return ; case "predeploy": return ; + case "initdeploy": + return ; } }; @@ -213,9 +216,6 @@ const ActionButton = styled.button` border-radius: 50%; cursor: pointer; color: #aaaabb; - :hover { - color: white; - } > span { font-size: 20px; @@ -241,6 +241,9 @@ const ServiceHeader = styled.div<{ border: 1px solid #494b4f; :hover { border: 1px solid #7a7b80; + ${ActionButton} { + color: white; + } } border-bottom-left-radius: ${(props) => (props.bordersRounded ? "" : "0")}; diff --git a/dashboard/src/main/home/app-dashboard/validate-apply/services-settings/ServiceList.tsx b/dashboard/src/main/home/app-dashboard/validate-apply/services-settings/ServiceList.tsx index fa82786197..b1b6246a07 100644 --- a/dashboard/src/main/home/app-dashboard/validate-apply/services-settings/ServiceList.tsx +++ b/dashboard/src/main/home/app-dashboard/validate-apply/services-settings/ServiceList.tsx @@ -16,7 +16,7 @@ import Spacer from "components/porter/Spacer"; import Text from "components/porter/Text"; import { useClusterContext } from "main/home/infrastructure-dashboard/ClusterContextProvider"; import { type ClientCluster } from "lib/clusters/types"; -import { type ClientServiceStatus } from "lib/hooks/useAppStatus"; +import { type AppServiceStatus } 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; + appServiceStatus?: AppServiceStatus; internalNetworkingDetails?: { namespace: string; appName: string; @@ -64,7 +64,7 @@ const ServiceList: React.FC = ({ fieldArrayName, isPredeploy = false, existingServiceNames = [], - serviceVersionStatus, + appServiceStatus, internalNetworkingDetails = { namespace: "", appName: "", @@ -238,7 +238,7 @@ const ServiceList: React.FC = ({ service={svc} update={update} remove={onRemove} - status={serviceVersionStatus?.[svc.name.value]} + status={appServiceStatus?.[svc.name.value]} internalNetworkingDetails={internalNetworkingDetails} existingServiceNames={existingServiceNames} /> diff --git a/dashboard/src/main/home/app-dashboard/validate-apply/services-settings/footers/ServiceStatusFooter.tsx b/dashboard/src/main/home/app-dashboard/validate-apply/services-settings/footers/ServiceStatusFooter.tsx index 57550a0314..9718b72083 100644 --- a/dashboard/src/main/home/app-dashboard/validate-apply/services-settings/footers/ServiceStatusFooter.tsx +++ b/dashboard/src/main/home/app-dashboard/validate-apply/services-settings/footers/ServiceStatusFooter.tsx @@ -1,15 +1,17 @@ -import React, { useState } from "react"; +import React, { useMemo } from "react"; import _ from "lodash"; -import AnimateHeight, { type Height } from "react-animate-height"; +import pluralize from "pluralize"; 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 Tag from "components/porter/Tag"; import Text from "components/porter/Text"; -import { type ClientServiceStatus } from "lib/hooks/useAppStatus"; +import { + type ClientServiceStatus, + type ServiceStatusDescriptor, +} from "lib/hooks/useAppStatus"; import { isClientServiceNotification } from "lib/porter-apps/notification"; import alert from "assets/alert-warning.svg"; @@ -17,78 +19,78 @@ import alert from "assets/alert-warning.svg"; import { useLatestRevision } from "../../../app-view/LatestRevisionContext"; type ServiceStatusFooterProps = { - status: ClientServiceStatus[]; + status: ClientServiceStatus; name: string; }; const ServiceStatusFooter: React.FC = ({ status, name, }) => { - const [expanded, setExpanded] = useState(false); const { latestClientNotifications, tabUrlGenerator } = useLatestRevision(); - const [height, setHeight] = useState(0); + + // group instances by revision number and status + const instanceGroups: Array<{ + revisionId: string; + revisionNumber: number; + status: ServiceStatusDescriptor; + numInstances: number; + restartCount: number; + }> = useMemo(() => { + return status.versionStatusList + .map((versionStatus) => { + const groupByStatus = _.groupBy( + versionStatus.instanceStatusList, + (instance) => instance.status + ); + return Object.keys(groupByStatus).map((status) => { + return { + revisionId: versionStatus.revisionId, + revisionNumber: versionStatus.revisionNumber, + status: status as ServiceStatusDescriptor, + numInstances: groupByStatus[status].length, + restartCount: groupByStatus[status].reduce( + (acc, instance) => acc + instance.restartCount, + 0 + ), + }; + }); + }) + .flat() + .filter((group) => group.status !== "unknown"); + }, [status]); return ( - <> - {status.map((versionStatus, i) => { +
+ {instanceGroups.map((instanceGroup, i) => { const versionNotifications = latestClientNotifications .filter(isClientServiceNotification) - .filter((n) => n.appRevisionId === versionStatus.revisionId) + .filter((n) => n.appRevisionId === instanceGroup.revisionId) .filter((n) => n.service.name.value === name); return (
- + - {match(versionStatus) - .with({ status: "failing" }, (vs) => { - return ( - <> - - - {vs.message} - - {vs.crashLoopReason && ( - - )} - - ); - }) - .with({ status: "spinningDown" }, (vs) => { - return ( - - - {vs.message} - - ); - }) - .with({ status: "running" }, (vs) => { - return ( - - - {vs.message} - - ); - }) - .exhaustive()} + + + {`${ + instanceGroup.numInstances + } ${pluralize( + "instance", + instanceGroup.numInstances + )} ${pluralize("is", instanceGroup.numInstances)} ${match( + instanceGroup + ) + .with({ status: "failing" }, () => "failing to run") + .with({ status: "pending" }, () => "pending") + .with({ status: "running" }, () => "running") + .otherwise(() => "")} at Version ${ + instanceGroup.revisionNumber + }`} + - {(versionStatus.restartCount ?? 0) > 0 && ( + {(instanceGroup.restartCount ?? 0) > 0 && ( - Restarts: {versionStatus.restartCount} + Restarts: {instanceGroup.restartCount} )} {versionNotifications.length > 0 && ( @@ -107,17 +109,10 @@ const ServiceStatusFooter: React.FC = ({ - {versionStatus.crashLoopReason && ( - - - {versionStatus.crashLoopReason} - - - )}
); })} - +
); }; @@ -152,11 +147,6 @@ const StatusDot = styled.div<{ color?: string }>` } `; -const I = styled.i` - font-size: 14px; - margin-right: 5px; -`; - const Running = styled.div` display: flex; align-items: center; @@ -185,32 +175,8 @@ const StyledStatusFooter = styled.div` } `; -const StyledStatusFooterTop = styled(StyledStatusFooter)<{ - expanded: boolean; -}>` +const StyledStatusFooterTop = styled(StyledStatusFooter)` height: 40px; - border-bottom: ${({ expanded }) => expanded && "0px"}; - border-bottom-left-radius: ${({ expanded }) => expanded && "0px"}; - border-bottom-right-radius: ${({ expanded }) => expanded && "0px"}; -`; - -const Message = styled.div` - padding: 20px; - background: #000000; - border-radius: 5px; - line-height: 1.5em; - border: 1px solid #aaaabb33; - font-family: monospace; - font-size: 13px; - display: flex; - align-items: center; - > img { - width: 13px; - margin-right: 20px; - } - width: 100%; - height: 101px; - overflow: hidden; `; const StyledContainer = styled.div<{ diff --git a/internal/porter_app/status.go b/internal/porter_app/status.go index 40b7ee74ee..56c600738a 100644 --- a/internal/porter_app/status.go +++ b/internal/porter_app/status.go @@ -60,6 +60,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 @@ -205,7 +206,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