فهرست منبع

preliminary work for status tab

Feroze Mohideen 2 سال پیش
والد
کامیت
b9f1cb7010

+ 2 - 2
api/server/handlers/porter_app/get_app_revision_status.go

@@ -37,7 +37,7 @@ func NewGetAppRevisionStatusHandler(
 
 // GetAppRevisionStatusResponse represents the response from the /apps/{porter_app_name}/revisions/{app_revision_id}/status endpoint
 type GetAppRevisionStatusResponse struct {
-	AppRevisionStatus porter_app.RevisionStatus `json:"app_revision_status"`
+	AppRevisionStatus porter_app.RevisionProgress `json:"app_revision_status"`
 }
 
 // GetAppRevisionStatusHandler returns the status of an app revision
@@ -71,7 +71,7 @@ func (c *GetAppRevisionStatusHandler) ServeHTTP(w http.ResponseWriter, r *http.R
 		return
 	}
 
-	revisionStatus := porter_app.RevisionStatus{
+	revisionStatus := porter_app.RevisionProgress{
 		PredeployStarted:     ccpResp.Msg.PredeployStarted,
 		PredeploySuccessful:  ccpResp.Msg.PredeploySuccessful,
 		PredeployFailed:      ccpResp.Msg.PredeployFailed,

+ 1 - 1
api/server/handlers/porter_app/list_app_revisions.go

@@ -100,7 +100,7 @@ func (c *ListAppRevisionsHandler) ServeHTTP(w http.ResponseWriter, r *http.Reque
 		DeploymentTargetId: request.DeploymentTargetID,
 	})
 
-	listAppRevisionsResp, err := c.Config().ClusterControlPlaneClient.ListAppRevisions(r.Context(), listAppRevisionsReq)
+	listAppRevisionsResp, err := c.Config().ClusterControlPlaneClient.ListAppRevisions(ctx, listAppRevisionsReq)
 	if err != nil {
 		err = telemetry.Error(ctx, span, err, "error listing app revisions")
 		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))

+ 83 - 22
api/server/handlers/porter_app/pod_status.go

@@ -1,9 +1,10 @@
 package porter_app
 
 import (
-	"fmt"
 	"net/http"
 
+	"connectrpc.com/connect"
+	porterv1 "github.com/porter-dev/api-contracts/generated/go/porter/v1"
 	"github.com/porter-dev/porter/api/server/authz"
 	"github.com/porter-dev/porter/api/server/handlers"
 	"github.com/porter-dev/porter/api/server/shared"
@@ -13,39 +14,44 @@ import (
 	"github.com/porter-dev/porter/api/types"
 	"github.com/porter-dev/porter/internal/deployment_target"
 	"github.com/porter-dev/porter/internal/models"
+	"github.com/porter-dev/porter/internal/porter_app"
 	"github.com/porter-dev/porter/internal/telemetry"
-	v1 "k8s.io/api/core/v1"
 )
 
-// PodStatusHandler is the handler for GET /apps/pods
-type PodStatusHandler struct {
+// ServiceStatusHandler is the handler for GET /apps/pods
+type ServiceStatusHandler struct {
 	handlers.PorterHandlerReadWriter
 	authz.KubernetesAgentGetter
 }
 
-// NewPodStatusHandler returns a new PodStatusHandler
-func NewPodStatusHandler(
+// NewServiceStatusHandler returns a new ServiceStatusHandler
+func NewServiceStatusHandler(
 	config *config.Config,
 	decoderValidator shared.RequestDecoderValidator,
 	writer shared.ResultWriter,
-) *PodStatusHandler {
-	return &PodStatusHandler{
+) *ServiceStatusHandler {
+	return &ServiceStatusHandler{
 		PorterHandlerReadWriter: handlers.NewDefaultPorterHandler(config, decoderValidator, writer),
 		KubernetesAgentGetter:   authz.NewOutOfClusterAgentGetter(config),
 	}
 }
 
-// PodStatusRequest is the expected format for a request body on GET /apps/pods
-type PodStatusRequest struct {
+// ServiceStatusRequest is the expected format for a request body on GET /apps/pods
+type ServiceStatusRequest struct {
 	DeploymentTargetID string `schema:"deployment_target_id"`
 	ServiceName        string `schema:"service"`
 }
 
-func (c *PodStatusHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+// ServiceStatusResponse is the expected format for a response body on GET /apps/pods
+type ServiceStatusResponse struct {
+	Status porter_app.ServiceStatus `json:"status"`
+}
+
+func (c *ServiceStatusHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
 	ctx, span := telemetry.NewSpan(r.Context(), "serve-pod-status")
 	defer span.End()
 
-	request := &PodStatusRequest{}
+	request := &ServiceStatusRequest{}
 	if ok := c.DecodeAndValidate(w, r, request); !ok {
 		err := telemetry.Error(ctx, span, nil, "invalid request")
 		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusBadRequest))
@@ -86,29 +92,84 @@ func (c *PodStatusHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
 	namespace := deploymentTarget.Namespace
 	telemetry.WithAttributes(span, telemetry.AttributeKV{Key: "namespace", Value: namespace})
 
+	app, err := c.Repo().PorterApp().ReadPorterAppByName(cluster.ID, appName)
+	if err != nil {
+		err = telemetry.Error(ctx, span, err, "error reading porter app by name")
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
+		return
+	}
+	if app == nil || app.ID == 0 {
+		err = telemetry.Error(ctx, span, nil, "app with name does not exist in project")
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusBadRequest))
+		return
+	}
+
+	telemetry.WithAttributes(span, telemetry.AttributeKV{Key: "app-id", Value: app.ID})
+
 	agent, err := c.GetAgent(r, cluster, "")
 	if err != nil {
 		err = telemetry.Error(ctx, span, err, "unable to get agent")
 		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
 		return
 	}
+	if agent == nil {
+		err = telemetry.Error(ctx, span, nil, "agent is nil")
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
+		return
+	}
+
+	listAppRevisionsReq := connect.NewRequest(&porterv1.ListAppRevisionsRequest{
+		ProjectId:          int64(project.ID),
+		AppId:              int64(app.ID),
+		DeploymentTargetId: request.DeploymentTargetID,
+	})
+
+	listAppRevisionsResp, err := c.Config().ClusterControlPlaneClient.ListAppRevisions(ctx, listAppRevisionsReq)
+	if err != nil {
+		err = telemetry.Error(ctx, span, err, "error listing app revisions")
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
+		return
+	}
 
-	pods := []v1.Pod{}
+	if listAppRevisionsResp == nil || listAppRevisionsResp.Msg == nil {
+		err = telemetry.Error(ctx, span, nil, "list app revisions response is nil")
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
+		return
+	}
+
+	appRevisions := listAppRevisionsResp.Msg.AppRevisions
+	if appRevisions == nil {
+		appRevisions = []*porterv1.AppRevision{}
+	}
+
+	var revisions []porter_app.Revision
+	for _, revision := range appRevisions {
+		encodedRevision, err := porter_app.EncodedRevisionFromProto(ctx, revision)
+		if err != nil {
+			err := telemetry.Error(ctx, span, err, "error getting encoded revision from proto")
+			c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
+			return
+		}
 
-	var selectors string
-	if request.ServiceName == "" {
-		selectors = fmt.Sprintf("porter.run/deployment-target-id=%s,porter.run/app-name=%s", request.DeploymentTargetID, appName)
-	} else {
-		selectors = fmt.Sprintf("porter.run/service-name=%s,porter.run/deployment-target-id=%s,porter.run/app-name=%s", request.ServiceName, request.DeploymentTargetID, appName)
+		revisions = append(revisions, encodedRevision)
 	}
-	podsList, err := agent.GetPodsByLabel(selectors, namespace)
+
+	serviceStatus, err := porter_app.GetServiceStatus(ctx, porter_app.GetServiceStatusInput{
+		DeploymentTarget: deploymentTarget,
+		Agent:            *agent,
+		AppName:          appName,
+		ServiceName:      request.ServiceName,
+		AppRevisions:     revisions,
+	})
 	if err != nil {
-		err = telemetry.Error(ctx, span, err, "unable to get pods by label")
+		err := telemetry.Error(ctx, span, err, "error getting service status")
 		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
 		return
 	}
 
-	pods = append(pods, podsList.Items...)
+	res := ServiceStatusResponse{
+		Status: serviceStatus,
+	}
 
-	c.WriteResult(w, r, pods)
+	c.WriteResult(w, r, res)
 }

+ 1 - 1
api/server/handlers/porter_app/status.go

@@ -39,7 +39,7 @@ type AppStatusRequest struct {
 }
 
 func (c *AppStatusHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
-	ctx, span := telemetry.NewSpan(r.Context(), "serve-app-logs")
+	ctx, span := telemetry.NewSpan(r.Context(), "serve-app-status")
 	defer span.End()
 
 	safeRW := ctx.Value(types.RequestCtxWebsocketKey).(*websocket.WebsocketSafeReadWriter)

+ 1 - 1
api/server/router/porter_app.go

@@ -1171,7 +1171,7 @@ func getPorterAppRoutes(
 		},
 	)
 
-	appPodStatusHandler := porter_app.NewPodStatusHandler(
+	appPodStatusHandler := porter_app.NewServiceStatusHandler(
 		config,
 		factory.GetDecoderValidator(),
 		factory.GetResultWriter(),

+ 27 - 0
dashboard/package-lock.json

@@ -54,6 +54,7 @@
         "jszip": "^3.10.1",
         "lodash": "^4.17.21",
         "markdown-to-jsx": "^7.0.1",
+        "pluralize": "^8.0.0",
         "qs": "^6.9.4",
         "random-word-slugs": "^0.1.6",
         "react": "^18.0.0",
@@ -107,6 +108,7 @@
         "@types/markdown-to-jsx": "^6.11.3",
         "@types/material-ui": "^0.21.8",
         "@types/node": "^12.12.62",
+        "@types/pluralize": "^0.0.33",
         "@types/qs": "^6.9.5",
         "@types/react": "^18.0.0",
         "@types/react-beautiful-dnd": "^13.1.4",
@@ -3366,6 +3368,12 @@
       "resolved": "https://registry.npmjs.org/@types/parse-json/-/parse-json-4.0.0.tgz",
       "integrity": "sha512-//oorEZjL6sbPcKUaCdIGlIUeH26mgzimjBB77G6XRgnDl/L5wOnpyBGRe/Mmf5CVW3PwEBE1NjiMZ/ssFh4wA=="
     },
+    "node_modules/@types/pluralize": {
+      "version": "0.0.33",
+      "resolved": "https://registry.npmjs.org/@types/pluralize/-/pluralize-0.0.33.tgz",
+      "integrity": "sha512-JOqsl+ZoCpP4e8TDke9W79FDcSgPAR0l6pixx2JHkhnRjvShyYiAYw2LVsnA7K08Y6DeOnaU6ujmENO4os/cYg==",
+      "dev": true
+    },
     "node_modules/@types/prop-types": {
       "version": "15.7.5",
       "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.5.tgz",
@@ -12924,6 +12932,14 @@
         "node": ">=8"
       }
     },
+    "node_modules/pluralize": {
+      "version": "8.0.0",
+      "resolved": "https://registry.npmjs.org/pluralize/-/pluralize-8.0.0.tgz",
+      "integrity": "sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA==",
+      "engines": {
+        "node": ">=4"
+      }
+    },
     "node_modules/popper.js": {
       "version": "1.16.1-lts",
       "resolved": "https://registry.npmjs.org/popper.js/-/popper.js-1.16.1-lts.tgz",
@@ -20413,6 +20429,12 @@
       "resolved": "https://registry.npmjs.org/@types/parse-json/-/parse-json-4.0.0.tgz",
       "integrity": "sha512-//oorEZjL6sbPcKUaCdIGlIUeH26mgzimjBB77G6XRgnDl/L5wOnpyBGRe/Mmf5CVW3PwEBE1NjiMZ/ssFh4wA=="
     },
+    "@types/pluralize": {
+      "version": "0.0.33",
+      "resolved": "https://registry.npmjs.org/@types/pluralize/-/pluralize-0.0.33.tgz",
+      "integrity": "sha512-JOqsl+ZoCpP4e8TDke9W79FDcSgPAR0l6pixx2JHkhnRjvShyYiAYw2LVsnA7K08Y6DeOnaU6ujmENO4os/cYg==",
+      "dev": true
+    },
     "@types/prop-types": {
       "version": "15.7.5",
       "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.5.tgz",
@@ -27939,6 +27961,11 @@
         "find-up": "^4.0.0"
       }
     },
+    "pluralize": {
+      "version": "8.0.0",
+      "resolved": "https://registry.npmjs.org/pluralize/-/pluralize-8.0.0.tgz",
+      "integrity": "sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA=="
+    },
     "popper.js": {
       "version": "1.16.1-lts",
       "resolved": "https://registry.npmjs.org/popper.js/-/popper.js-1.16.1-lts.tgz",

+ 2 - 0
dashboard/package.json

@@ -49,6 +49,7 @@
     "jszip": "^3.10.1",
     "lodash": "^4.17.21",
     "markdown-to-jsx": "^7.0.1",
+    "pluralize": "^8.0.0",
     "qs": "^6.9.4",
     "random-word-slugs": "^0.1.6",
     "react": "^18.0.0",
@@ -114,6 +115,7 @@
     "@types/markdown-to-jsx": "^6.11.3",
     "@types/material-ui": "^0.21.8",
     "@types/node": "^12.12.62",
+    "@types/pluralize": "^0.0.33",
     "@types/qs": "^6.9.5",
     "@types/react": "^18.0.0",
     "@types/react-beautiful-dnd": "^13.1.4",

+ 188 - 204
dashboard/src/lib/hooks/useAppStatus.ts

@@ -1,215 +1,199 @@
+import { useEffect, useState } from "react";
 import _ from "lodash";
-import { useEffect, useMemo, useState } from "react";
+import pluralize from "pluralize";
+import z from "zod";
+
 import api from "shared/api";
-import { NewWebsocketOptions, useWebsockets } from "shared/hooks/useWebsockets";
-import { useRevisionList } from "./useRevisionList";
+import {
+  useWebsockets,
+  type NewWebsocketOptions,
+} from "shared/hooks/useWebsockets";
 import { valueExists } from "shared/util";
 
-export type PorterAppVersionStatus = {
-    status: 'running' | 'spinningDown' | 'failing';
-    message: string;
-    crashLoopReason: string;
-}
-
-type ClientPod = {
-    revisionId: string,
-    helmRevision: string,
-    crashLoopReason: string,
-    isFailing: boolean,
-    replicaSetName: string,
-}
-
-export const useAppStatus = (
-    {
-        projectId,
-        clusterId,
-        serviceNames,
-        deploymentTargetId,
-        appName,
-        kind = "pod",
-    }: {
-        projectId: number,
-        clusterId: number,
-        serviceNames: string[],
-        deploymentTargetId: string,
-        appName: string,
-        kind?: string,
-    }
-) => {
-    const [servicePodMap, setServicePodMap] = useState<Record<string, ClientPod[]>>({});
-
-    const { revisionIdToNumber } = useRevisionList({ appName, deploymentTargetId, projectId, clusterId });
-
-    const {
-        newWebsocket,
-        openWebsocket,
-        closeAllWebsockets,
-        closeWebsocket,
-    } = useWebsockets();
-
-    const setupWebsocket = (
-        serviceName: string,
-    ) => {
-        const selectors = `porter.run/service-name=${serviceName},porter.run/deployment-target-id=${deploymentTargetId}`;
-        const apiEndpoint = `/api/projects/${projectId}/clusters/${clusterId}/apps/${kind}/status?selectors=${selectors}`;
-        const websocketKey = `${serviceName}-${Math.random().toString(36).substring(2, 15)}`
-
-        const options: NewWebsocketOptions = {};
-        options.onopen = () => {
-            // console.log("opening status websocket for service: " + serviceName)
-        };
-
-        options.onmessage = async (evt: MessageEvent) => {
-            await updatePods(serviceName);
-        };
-
-        options.onclose = () => {
-            // console.log("closing status websocket for service: " + serviceName)
-        };
-
-        options.onerror = (err: ErrorEvent) => {
-            closeWebsocket(websocketKey);
-        };
-
-        newWebsocket(websocketKey, apiEndpoint, options);
-        openWebsocket(websocketKey);
+export type ClientServiceStatus = {
+  status: "running" | "spinningDown" | "failing";
+  message: string;
+  crashLoopReason: string;
+  restartCount?: number;
+};
+
+const serviceStatusValidator = z.object({
+  service_name: z.string(),
+  revision_status_list: z.array(
+    z.object({
+      revision_id: z.string(),
+      revision_number: z.number(),
+      instance_status_list: z.array(
+        z.object({
+          status: z.union([
+            z.literal("PENDING"),
+            z.literal("RUNNING"),
+            z.literal("FAILED"),
+          ]),
+          restart_count: z.number(),
+          creation_timestamp: z.string(),
+        })
+      ),
+    })
+  ),
+});
+type SerializedServiceStatus = z.infer<typeof serviceStatusValidator>;
+
+export const useAppStatus = ({
+  projectId,
+  clusterId,
+  serviceNames,
+  deploymentTargetId,
+  appName,
+  kind = "pod",
+}: {
+  projectId: number;
+  clusterId: number;
+  serviceNames: string[];
+  deploymentTargetId: string;
+  appName: string;
+  kind?: string;
+}): { serviceVersionStatus: Record<string, ClientServiceStatus[]> } => {
+  const [serviceStatusMap, setServiceStatusMap] = useState<
+    Record<string, SerializedServiceStatus>
+  >({});
+
+  const { newWebsocket, openWebsocket, closeAllWebsockets, closeWebsocket } =
+    useWebsockets();
+
+  const setupWebsocket = (serviceName: string): void => {
+    const selectors = `porter.run/service-name=${serviceName},porter.run/deployment-target-id=${deploymentTargetId}`;
+    const apiEndpoint = `/api/projects/${projectId}/clusters/${clusterId}/apps/${kind}/status?selectors=${selectors}`;
+    const websocketKey = `${serviceName}-${Math.random()
+      .toString(36)
+      .substring(2, 15)}`;
+
+    const options: NewWebsocketOptions = {};
+    options.onopen = () => {
+      // console.log("opening status websocket for service: " + serviceName)
     };
 
-    const updatePods = async (serviceName: string) => {
-        try {
-            const res = await api.appPodStatus(
-                "<token>",
-                {
-                    deployment_target_id: deploymentTargetId,
-                    service: serviceName,
-                },
-                {
-                    project_id: projectId,
-                    cluster_id: clusterId,
-                    app_name: appName,
-                }
-            );
-            // TODO: type the response
-            const data = res?.data as any[];
-            let newPods = data
-                // Parse only data that we need
-                .map((pod: any) => {
-                    const replicaSetName =
-                        Array.isArray(pod?.metadata?.ownerReferences) &&
-                        pod?.metadata?.ownerReferences[0]?.name;
-                    const containerStatus =
-                        Array.isArray(pod?.status?.containerStatuses) &&
-                        pod?.status?.containerStatuses[0];
-
-                    // const restartCount = containerStatus
-                    //     ? containerStatus.restartCount
-                    //     : "N/A";
-
-                    // const podAge = timeFormat("%H:%M:%S %b %d, '%y")(
-                    //     new Date(pod?.metadata?.creationTimestamp)
-                    // );
-
-                    const isFailing = containerStatus?.state?.waiting?.reason === "CrashLoopBackOff" ?? false;
-                    const crashLoopReason = containerStatus?.lastState?.terminated?.message ?? "";
-
-                    return {
-                        // namespace: pod?.metadata?.namespace,
-                        // name: pod?.metadata?.name,
-                        // phase: pod?.status?.phase,
-                        // status: pod?.status,
-                        // restartCount,
-                        // containerStatus,
-                        // podAge: pod?.metadata?.creationTimestamp ? podAge : "N/A",
-                        replicaSetName,
-                        revisionId: pod?.metadata?.labels?.["porter.run/app-revision-id"],
-                        helmRevision: pod?.metadata?.annotations?.["helm.sh/revision"] || "N/A",
-                        crashLoopReason,
-                        isFailing
-                    };
-                });
-            setServicePodMap((prevState) => ({
-                ...prevState,
-                [serviceName]: newPods,
-            }));
-        } catch (error) {
-            // TODO: handle error
-        }
+    options.onmessage = async () => {
+      void updatePods(serviceName);
     };
 
-    useEffect(() => {
-        Promise.all(serviceNames.map(updatePods));
-        for (let serviceName of serviceNames) {
-            setupWebsocket(serviceName);
+    options.onclose = () => {
+      // console.log("closing status websocket for service: " + serviceName)
+    };
+
+    options.onerror = () => {
+      closeWebsocket(websocketKey);
+    };
+
+    newWebsocket(websocketKey, apiEndpoint, options);
+    openWebsocket(websocketKey);
+  };
+
+  const updatePods = async (serviceName: string): Promise<void> => {
+    try {
+      const res = await api.appPodStatus(
+        "<token>",
+        {
+          deployment_target_id: deploymentTargetId,
+          service: serviceName,
+        },
+        {
+          project_id: projectId,
+          cluster_id: clusterId,
+          app_name: appName,
         }
-        return () => closeAllWebsockets();
-    }, [projectId, clusterId, deploymentTargetId, appName]);
-
-    const processReplicaSetArray = (replicaSetArray: ClientPod[][]): PorterAppVersionStatus[] => {
-        return replicaSetArray.map((replicaSet, i) => {
-            let status: 'running' | 'failing' | 'spinningDown' = "running";
-            let message = "";
-
-            const version = revisionIdToNumber[replicaSet[0].revisionId];
-
-            if (!version) {
-                return undefined;
-            }
-
-            if (replicaSet.some((r) => r.crashLoopReason !== "") || replicaSet.some((r) => r.isFailing)) {
-                status = "failing";
-                message = `${replicaSet.length} instance${replicaSet.length === 1 ? "" : "s"} ${replicaSet.length === 1 ? "is" : "are"
-                    } failing to run Version ${version}`;
-            } else if (
-                // last check ensures that we don't say 'spinning down' unless there exists a version status above it
-                i > 0 && replicaSetArray[i - 1].every(p => !p.isFailing) && revisionIdToNumber[replicaSetArray[i - 1][0].revisionId] != null
-            ) {
-                status = "spinningDown";
-                message = `${replicaSet.length} instance${replicaSet.length === 1 ? "" : "s"} ${replicaSet.length === 1 ? "is" : "are"
-                    } still running at Version ${version}. Attempting to spin down...`;
-            } else {
-                status = "running";
-                message = `${replicaSet.length} instance${replicaSet.length === 1 ? "" : "s"} ${replicaSet.length === 1 ? "is" : "are"
-                    } running at Version ${version}`;
-            }
-
-            const crashLoopReason =
-                replicaSet.find((r) => r.crashLoopReason !== "")?.crashLoopReason || "";
-
-            return {
-                status,
-                message,
-                crashLoopReason,
-            };
-        }).filter(valueExists);
+      );
+
+      const data = await z
+        .object({ status: serviceStatusValidator })
+        .parseAsync(res.data);
+      setServiceStatusMap((prevState) => ({
+        ...prevState,
+        [serviceName]: data.status,
+      }));
+    } catch (error) {}
+  };
+
+  useEffect(() => {
+    void Promise.all(serviceNames.map(updatePods));
+    for (const serviceName of serviceNames) {
+      setupWebsocket(serviceName);
     }
-
-    const serviceVersionStatus: Record<string, PorterAppVersionStatus[]> = useMemo(() => {
-        const serviceReplicaSetMap = Object.fromEntries(Object.keys(servicePodMap).map((serviceName) => {
-            const pods = servicePodMap[serviceName];
-            const replicaSetMap = _.sortBy(pods, ["helmRevision"])
-                .reverse()
-                .reduce<ClientPod[][]>(function (
-                    prev,
-                    currentPod,
-                    i
-                ) {
-                    if (
-                        !i ||
-                        prev[prev.length - 1][0].replicaSetName !== currentPod.replicaSetName
-                    ) {
-                        return prev.concat([[currentPod]]);
-                    }
-                    prev[prev.length - 1].push(currentPod);
-                    return prev;
-                }, []);
-
-            return [serviceName, processReplicaSetArray(replicaSetMap)];
-        }));
-
-        return serviceReplicaSetMap;
-    }, [JSON.stringify(servicePodMap), JSON.stringify(revisionIdToNumber)]);
-
-    return {
-        serviceVersionStatus,
+    return () => {
+      closeAllWebsockets();
     };
-};
+  }, [projectId, clusterId, deploymentTargetId, appName]);
+
+  const deserializeServiceStatus = (
+    serviceStatus: SerializedServiceStatus
+  ): ClientServiceStatus[] => {
+    return 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,
+          });
+        }
+        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,
+          });
+        }
+        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,
+          });
+        }
+        return versionStatuses;
+      })
+      .filter(valueExists);
+  };
+
+  return {
+    serviceVersionStatus: _.mapValues(
+      serviceStatusMap,
+      deserializeServiceStatus
+    ),
+  };
+};

+ 2 - 2
dashboard/src/main/home/app-dashboard/app-view/tabs/Overview.tsx

@@ -38,7 +38,7 @@ const Overview: React.FC<Props> = ({ buttonStatus }) => {
   const { serviceVersionStatus } = useAppStatus({
     projectId,
     clusterId,
-    serviceNames: Object.keys(latestProto.services),
+    serviceNames: latestProto.serviceList.map((s) => s.name),
     deploymentTargetId: deploymentTarget.id,
     appName: latestProto.name,
   });
@@ -71,7 +71,7 @@ const Overview: React.FC<Props> = ({ buttonStatus }) => {
       <ServiceList
         addNewText={"Add a new service"}
         fieldArrayName={"app.services"}
-        existingServiceNames={Object.keys(latestProto.services)}
+        existingServiceNames={latestProto.serviceList.map((s) => s.name)}
         serviceVersionStatus={serviceVersionStatus}
         internalNetworkingDetails={{
           namespace: deploymentTarget.namespace,

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

@@ -89,7 +89,7 @@ const NotificationTile: React.FC<Props> = ({
               </Tag>
             </Container>
           )}
-          {matchingVersionNumber && (
+          {matchingVersionNumber !== 0 && (
             <Container row style={{ width: "200px" }}>
               <Tag hoverable={false}>
                 <Text>{`Version ${matchingVersionNumber}`}</Text>

+ 3 - 3
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 PorterAppVersionStatus } from "lib/hooks/useAppStatus";
+import { type ClientServiceStatus } from "lib/hooks/useAppStatus";
 import useResizeObserver from "lib/hooks/useResizeObserver";
 import { type PorterAppFormData } from "lib/porter-apps";
 import { type ClientService } from "lib/porter-apps/services";
@@ -29,7 +29,7 @@ type ServiceProps = {
     "app.services" | "app.predeploy"
   >;
   remove: (index: number) => void;
-  status?: PorterAppVersionStatus[];
+  status?: ClientServiceStatus[];
   maxCPU: number;
   maxRAM: number;
   clusterContainsGPUNodes: boolean;
@@ -276,7 +276,7 @@ const ServiceHeader = styled.div<{
     border-radius: 20px;
     margin-left: -10px;
     transform: ${(props: { showExpanded?: boolean }) =>
-    props.showExpanded ? "" : "rotate(-90deg)"};
+      props.showExpanded ? "" : "rotate(-90deg)"};
   }
 `;
 

+ 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 PorterAppVersionStatus } from "lib/hooks/useAppStatus";
+import { type ClientServiceStatus } 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, PorterAppVersionStatus[]>;
+  serviceVersionStatus?: Record<string, ClientServiceStatus[]>;
   internalNetworkingDetails?: {
     namespace: string;
     appName: string;

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

@@ -1,140 +1,137 @@
 import React, { useState } from "react";
+import _ from "lodash";
+import AnimateHeight, { type Height } from "react-animate-height";
 import styled from "styled-components";
+import { match } from "ts-pattern";
 
-import Text from "components/porter/Text";
-import Container from "components/porter/Container";
 import Button from "components/porter/Button";
-
-import AnimateHeight, { type Height } from "react-animate-height";
-import _ from "lodash";
+import Container from "components/porter/Container";
 import Link from "components/porter/Link";
-import { type PorterAppVersionStatus } from "lib/hooks/useAppStatus";
-import { match } from "ts-pattern";
+import Spacer from "components/porter/Spacer";
+import Text from "components/porter/Text";
+import { type ClientServiceStatus } from "lib/hooks/useAppStatus";
+
 import { useLatestRevision } from "../../app-view/LatestRevisionContext";
 import TriggerJobButton from "../jobs/TriggerJobButton";
-import Spacer from "components/porter/Spacer";
 
 type ServiceStatusFooterProps = {
-    serviceName: string;
-    status: PorterAppVersionStatus[];
-    isJob: boolean,
-}
+  serviceName: string;
+  status: ClientServiceStatus[];
+  isJob: boolean;
+};
 const ServiceStatusFooter: React.FC<ServiceStatusFooterProps> = ({
-    serviceName,
-    status,
-    isJob
+  serviceName,
+  status,
+  isJob,
 }) => {
-    const [expanded, setExpanded] = useState<boolean>(false);
-    const { latestProto, projectId, clusterId, deploymentTarget, appName } = useLatestRevision();
-    const [height, setHeight] = useState<Height>(0);
+  const [expanded, setExpanded] = useState<boolean>(false);
+  const { latestProto, projectId, clusterId, deploymentTarget, appName } =
+    useLatestRevision();
+  const [height, setHeight] = useState<Height>(0);
 
-    if (isJob) {
-        return (
-            <StyledStatusFooter>
+  if (isJob) {
+    return (
+      <StyledStatusFooter>
+        <Container row>
+          <Link
+            to={`/apps/${latestProto.name}/job-history?service=${serviceName}`}
+          >
+            <Button
+              onClick={() => {}}
+              height="30px"
+              width="87px"
+              color="#ffffff11"
+              withBorder
+            >
+              <I className="material-icons">open_in_new</I>
+              History
+            </Button>
+          </Link>
+          <Spacer inline x={1} />
+          <TriggerJobButton
+            projectId={projectId}
+            clusterId={clusterId}
+            appName={appName}
+            jobName={serviceName}
+            deploymentTargetId={deploymentTarget.id}
+          />
+        </Container>
+      </StyledStatusFooter>
+    );
+  }
 
-                <Container row>
-                    {/*
-            <Mi className="material-icons">check</Mi>
-            <Text color="helper">
-              Last run succeeded at 12:39 PM on 4/13/23
-            </Text>
-            */}
-                    <Link to={`/apps/${latestProto.name}/job-history?service=${serviceName}`}>
-                        <Button
-                            onClick={() => { }}
-                            height="30px"
-                            width="87px"
+  return (
+    <>
+      {status.map((versionStatus, i) => {
+        return (
+          <div key={i}>
+            <StyledStatusFooterTop expanded={expanded}>
+              <StyledContainer row spaced>
+                {match(versionStatus)
+                  .with({ status: "failing" }, (vs) => {
+                    return (
+                      <>
+                        <Running>
+                          <StatusDot color="#ff0000" />
+                          <Text color="helper">{vs.message}</Text>
+                        </Running>
+                        {vs.crashLoopReason && (
+                          <Button
+                            onClick={() => {
+                              expanded ? setHeight(0) : setHeight(122);
+                              setExpanded(!expanded);
+                            }}
+                            height="20px"
                             color="#ffffff11"
                             withBorder
-                        >
-                            <I className="material-icons">open_in_new</I>
-                            History
-                        </Button>
-                    </Link>
-                    <Spacer inline x={1}/>
-                    <TriggerJobButton projectId={projectId} clusterId={clusterId} appName={appName} jobName={serviceName} deploymentTargetId={deploymentTarget.id} />
-                </Container>
-
-            </StyledStatusFooter>
-        );
-    }
-
-    return (
-        <>
-            {status.map((versionStatus, i) => {
-                return (
-                    <div key={i}>
-                        <StyledStatusFooterTop expanded={expanded}>
-                            <StyledContainer row spaced>
-                                {match(versionStatus)
-                                    .with({ status: "failing" }, (vs) => {
-                                        return (
-                                            <>
-                                                <Running>
-                                                    <StatusDot color="#ff0000" />
-                                                    <Text color="helper">
-                                                        {vs.message}
-                                                    </Text>
-                                                </Running>
-                                                {vs.crashLoopReason &&
-                                                    <Button
-                                                        onClick={() => {
-                                                            expanded ? setHeight(0) : setHeight(122);
-                                                            setExpanded(!expanded);
-                                                        }}
-                                                        height="20px"
-                                                        color="#ffffff11"
-                                                        withBorder
-                                                    >
-                                                        {expanded ? (
-                                                            <I className="material-icons">arrow_drop_up</I>
-                                                        ) : (
-                                                            <I className="material-icons">arrow_drop_down</I>
-                                                        )}
-                                                        <Text color="helper">See failure reason</Text>
-                                                    </Button>
-                                                }
-                                            </>
-                                        )
-                                    })
-                                    .with({ status: "spinningDown" }, (vs) => {
-                                        return (
-                                            <Running>
-                                                <StatusDot color="#FFA500" />
-                                                <Text color="helper">
-                                                    {vs.message}
-                                                </Text>
-                                            </Running>
-                                        )
-                                    })
-                                    .with({ status: "running" }, (vs) => {
-                                        return (
-                                            <Running>
-                                                <StatusDot />
-                                                <Text color="helper">
-                                                    {vs.message}
-                                                </Text>
-                                            </Running>
-                                        )
-                                    })
-                                    .exhaustive()
-                                }
-                            </StyledContainer>
-                        </StyledStatusFooterTop>
-                        {versionStatus.crashLoopReason && (
-                            <AnimateHeight height={height}>
-                                <StyledStatusFooter>
-                                    <Message>
-                                        {versionStatus.crashLoopReason}
-                                    </Message>
-                                </StyledStatusFooter>
-                            </AnimateHeight>
+                          >
+                            {expanded ? (
+                              <I className="material-icons">arrow_drop_up</I>
+                            ) : (
+                              <I className="material-icons">arrow_drop_down</I>
+                            )}
+                            <Text color="helper">See failure reason</Text>
+                          </Button>
                         )}
-                    </div>
-                );
-            })}
-        </>
-    );
+                      </>
+                    );
+                  })
+                  .with({ status: "spinningDown" }, (vs) => {
+                    return (
+                      <Running>
+                        <StatusDot color="#FFA500" />
+                        <Text color="helper">{vs.message}</Text>
+                      </Running>
+                    );
+                  })
+                  .with({ status: "running" }, (vs) => {
+                    return (
+                      <Running>
+                        <StatusDot />
+                        <Text color="helper">{vs.message}</Text>
+                      </Running>
+                    );
+                  })
+                  .exhaustive()}
+                {(versionStatus.restartCount ?? 0) > 0 && (
+                  <Text color="helper">
+                    Restarts: {versionStatus.restartCount}
+                  </Text>
+                )}
+              </StyledContainer>
+            </StyledStatusFooterTop>
+            {versionStatus.crashLoopReason && (
+              <AnimateHeight height={height}>
+                <StyledStatusFooter>
+                  <Message>{versionStatus.crashLoopReason}</Message>
+                </StyledStatusFooter>
+              </AnimateHeight>
+            )}
+          </div>
+        );
+      })}
+    </>
+  );
 };
 
 export default ServiceStatusFooter;
@@ -168,34 +165,11 @@ const StatusDot = styled.div<{ color?: string }>`
   }
 `;
 
-const Mi = styled.i`
-  font-size: 16px;
-  margin-right: 7px;
-  margin-top: -1px;
-  color: rgb(56, 168, 138);
-`;
-
 const I = styled.i`
   font-size: 14px;
   margin-right: 5px;
 `;
 
-const StatusCircle = styled.div<{
-    percentage?: any;
-    dashed?: boolean;
-}>`
-  width: 16px;
-  height: 16px;
-  border-radius: 50%;
-  margin-right: 10px;
-  background: conic-gradient(
-    from 0deg,
-    #ffffff33 ${(props) => props.percentage},
-    #ffffffaa 0% ${(props) => props.percentage}
-  );
-  border: ${(props) => (props.dashed ? "1px dashed #ffffff55" : "none")};
-`;
-
 const Running = styled.div`
   display: flex;
   align-items: center;
@@ -224,8 +198,8 @@ const StyledStatusFooter = styled.div`
   }
 `;
 
-const StyledStatusFooterTop = styled(StyledStatusFooter) <{
-    expanded: boolean;
+const StyledStatusFooterTop = styled(StyledStatusFooter)<{
+  expanded: boolean;
 }>`
   height: 40px;
   border-bottom: ${({ expanded }) => expanded && "0px"};
@@ -253,12 +227,12 @@ const Message = styled.div`
 `;
 
 const StyledContainer = styled.div<{
-    row: boolean;
-    spaced: boolean;
+  row: boolean;
+  spaced: boolean;
 }>`
   display: ${(props) => (props.row ? "flex" : "block")};
   align-items: center;
   justify-content: ${(props) =>
-        props.spaced ? "space-between" : "flex-start"};
+    props.spaced ? "space-between" : "flex-start"};
   width: 100%;
 `;

+ 2 - 2
internal/porter_app/revisions.go

@@ -41,8 +41,8 @@ type Revision struct {
 	AppInstanceID uuid.UUID `json:"app_instance_id"`
 }
 
-// RevisionStatus describes the status of a revision
-type RevisionStatus struct {
+// RevisionProgress describes the progress of a revision in its lifecycle
+type RevisionProgress struct {
 	// PredeployStarted is true if the predeploy process has started
 	PredeployStarted bool `json:"predeploy_started"`
 	// PredeploySuccessful is true if the predeploy process has completed successfully

+ 240 - 0
internal/porter_app/status.go

@@ -0,0 +1,240 @@
+package porter_app
+
+import (
+	"context"
+	"errors"
+	"fmt"
+	"time"
+
+	"github.com/porter-dev/porter/internal/deployment_target"
+	"github.com/porter-dev/porter/internal/kubernetes"
+	"github.com/porter-dev/porter/internal/telemetry"
+	v1 "k8s.io/api/core/v1"
+)
+
+const (
+	LabelKey_DeploymentTargetID = "porter.run/deployment-target-id"
+	LabelKey_AppName            = "porter.run/app-name"
+	LabelKey_ServiceName        = "porter.run/service-name"
+	LabelKey_AppRevisionID      = "porter.run/app-revision-id"
+)
+
+// ServiceStatus describes the status of a service of a porter app
+type ServiceStatus struct {
+	ServiceName        string           `json:"service_name"`
+	RevisionStatusList []RevisionStatus `json:"revision_status_list"`
+}
+
+// RevisionStatus describes the status of a revision of a service of a porter app
+type RevisionStatus struct {
+	RevisionID         string           `json:"revision_id"`
+	RevisionNumber     int              `json:"revision_number"`
+	InstanceStatusList []InstanceStatus `json:"instance_status_list"`
+}
+
+// InstanceStatusDescriptor is a string that summarizes the status of an instance
+type InstanceStatusDescriptor string
+
+const (
+	// InstanceStatusDescriptor_Pending means the instance is pending
+	InstanceStatusDescriptor_Pending InstanceStatusDescriptor = "PENDING"
+	// InstanceStatusDescriptor_Running means the instance is running normally
+	InstanceStatusDescriptor_Running InstanceStatusDescriptor = "RUNNING"
+	// InstanceStatusDescriptor_Failed means the instance has failed
+	InstanceStatusDescriptor_Failed InstanceStatusDescriptor = "FAILED"
+)
+
+// CrashLoopBackOff is a string that describes the status of a pod that is in a crash loop backoff
+const CrashLoopBackOff = "CrashLoopBackOff"
+
+// InstanceStatus describes the status of an instance of a revision of a service of a porter app
+type InstanceStatus struct {
+	Status            InstanceStatusDescriptor `json:"status"`
+	RestartCount      int                      `json:"restart_count"`
+	CreationTimestamp time.Time                `json:"creation_timestamp"`
+}
+
+// GetServiceStatusInput is the input type for GetServiceStatus
+type GetServiceStatusInput struct {
+	DeploymentTarget deployment_target.DeploymentTarget
+	Agent            kubernetes.Agent
+	AppName          string
+	ServiceName      string
+	AppRevisions     []Revision
+}
+
+// GetServiceStatus returns the status of a service of a porter app
+func GetServiceStatus(ctx context.Context, inp GetServiceStatusInput) (ServiceStatus, error) {
+	ctx, span := telemetry.NewSpan(ctx, "get-service-status")
+	defer span.End()
+
+	telemetry.WithAttributes(span,
+		telemetry.AttributeKV{Key: "app-name", Value: inp.AppName},
+		telemetry.AttributeKV{Key: "service-name", Value: inp.ServiceName},
+		telemetry.AttributeKV{Key: "deployment-target-id", Value: inp.DeploymentTarget.ID},
+		telemetry.AttributeKV{Key: "deployment-target-namespace", Value: inp.DeploymentTarget.Namespace},
+	)
+
+	serviceStatus := ServiceStatus{
+		ServiceName: inp.ServiceName,
+	}
+
+	if inp.AppName == "" {
+		return serviceStatus, telemetry.Error(ctx, span, nil, "must provide app name")
+	}
+	if inp.ServiceName == "" {
+		return serviceStatus, telemetry.Error(ctx, span, nil, "must provide service name")
+	}
+	if inp.DeploymentTarget.ID == "" {
+		return serviceStatus, telemetry.Error(ctx, span, nil, "must provide deployment target id")
+	}
+	if inp.DeploymentTarget.Namespace == "" {
+		return serviceStatus, telemetry.Error(ctx, span, nil, "must provide deployment target namespace")
+	}
+
+	selectorString := fmt.Sprintf(
+		"%s=%s,%s=%s,%s=%s",
+		LabelKey_DeploymentTargetID, inp.DeploymentTarget.ID,
+		LabelKey_AppName, inp.AppName,
+		LabelKey_ServiceName, inp.ServiceName,
+	)
+
+	podList, err := inp.Agent.GetPodsByLabel(selectorString, inp.DeploymentTarget.Namespace)
+	if err != nil {
+		return serviceStatus, telemetry.Error(ctx, span, err, "error getting pods by label")
+	}
+	if podList == nil {
+		return serviceStatus, telemetry.Error(ctx, span, nil, "pod list is nil")
+	}
+
+	revisionStatusList, err := revisionStatusFromPods(ctx, revisionStatusFromPodsInput{
+		PodList:      *podList,
+		AppRevisions: inp.AppRevisions,
+		AppName:      inp.AppName,
+		ServiceName:  inp.ServiceName,
+	})
+	if err != nil {
+		return serviceStatus, telemetry.Error(ctx, span, err, "error processing pods")
+	}
+
+	serviceStatus.RevisionStatusList = revisionStatusList
+	return serviceStatus, nil
+}
+
+type revisionStatusFromPodsInput struct {
+	PodList      v1.PodList
+	AppRevisions []Revision
+	AppName      string
+	ServiceName  string
+}
+
+func revisionStatusFromPods(ctx context.Context, inp revisionStatusFromPodsInput) ([]RevisionStatus, error) {
+	ctx, span := telemetry.NewSpan(ctx, "revision-status-from-pods")
+	defer span.End()
+
+	telemetry.WithAttributes(span, telemetry.AttributeKV{Key: "num-pods", Value: len(inp.PodList.Items)})
+
+	revisionStatusList := []RevisionStatus{}
+
+	revisionToInstanceStatusMap := map[string][]InstanceStatus{}
+	for _, pod := range inp.PodList.Items {
+		revisionID := pod.Labels[LabelKey_AppRevisionID]
+		if revisionID == "" {
+			telemetry.WithAttributes(span, telemetry.AttributeKV{Key: "pod-name", Value: pod.Name})
+			return revisionStatusList, telemetry.Error(ctx, span, nil, "pod does not have revision id label")
+		}
+
+		instanceStatusList, ok := revisionToInstanceStatusMap[revisionID]
+		if !ok {
+			instanceStatusList = []InstanceStatus{}
+		}
+
+		instanceStatus, err := instanceStatusFromPod(ctx, instanceStatusFromPodInput{
+			Pod:         pod,
+			AppName:     inp.AppName,
+			ServiceName: inp.ServiceName,
+		})
+		if err != nil {
+			continue
+		}
+
+		instanceStatusList = append(instanceStatusList, instanceStatus)
+		revisionToInstanceStatusMap[revisionID] = instanceStatusList
+	}
+
+	for revisionId, instanceStatusList := range revisionToInstanceStatusMap {
+		revisionNumber, err := getRevisionNumberFromRevisionId(revisionId, inp.AppRevisions)
+		if err != nil {
+			telemetry.WithAttributes(span, telemetry.AttributeKV{Key: "revision-id", Value: revisionId})
+			return revisionStatusList, telemetry.Error(ctx, span, err, "error getting revision number from revision id")
+		}
+		revisionStatus := RevisionStatus{
+			RevisionID:         revisionId,
+			RevisionNumber:     revisionNumber,
+			InstanceStatusList: instanceStatusList,
+		}
+		revisionStatusList = append(revisionStatusList, revisionStatus)
+	}
+
+	return revisionStatusList, nil
+}
+
+type instanceStatusFromPodInput struct {
+	Pod         v1.Pod
+	AppName     string
+	ServiceName string
+}
+
+func instanceStatusFromPod(ctx context.Context, inp instanceStatusFromPodInput) (InstanceStatus, error) {
+	ctx, span := telemetry.NewSpan(ctx, "instance-status-from-pod")
+	defer span.End()
+
+	telemetry.WithAttributes(span, telemetry.AttributeKV{Key: "pod-name", Value: inp.Pod.Name})
+
+	instanceStatus := InstanceStatus{}
+
+	// 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
+	// there may be other containers (like the sidecar container for jobs), but we only care about the app container for reporting status
+	appContainerName := fmt.Sprintf("%s-%s", inp.AppName, inp.ServiceName)
+	var appContainerStatus v1.ContainerStatus
+	for _, containerStatus := range inp.Pod.Status.ContainerStatuses {
+		if containerStatus.Name == appContainerName {
+			appContainerStatus = containerStatus
+			break
+		}
+	}
+	if appContainerStatus.Name == "" {
+		return instanceStatus, telemetry.Error(ctx, span, nil, "app container not found")
+	}
+
+	instanceStatus.CreationTimestamp = inp.Pod.CreationTimestamp.Time
+	instanceStatus.RestartCount = int(appContainerStatus.RestartCount)
+
+	switch inp.Pod.Status.Phase {
+	case v1.PodPending:
+		instanceStatus.Status = InstanceStatusDescriptor_Pending
+	case v1.PodRunning:
+		instanceStatus.Status = InstanceStatusDescriptor_Running
+	case v1.PodFailed:
+		instanceStatus.Status = InstanceStatusDescriptor_Failed
+	}
+
+	if appContainerStatus.State.Waiting != nil && appContainerStatus.State.Waiting.Reason == CrashLoopBackOff {
+		instanceStatus.Status = InstanceStatusDescriptor_Failed
+	}
+	if appContainerStatus.State.Terminated != nil {
+		instanceStatus.Status = InstanceStatusDescriptor_Failed
+	}
+
+	return instanceStatus, nil
+}
+
+func getRevisionNumberFromRevisionId(revisionId string, appRevisions []Revision) (int, error) {
+	for _, revision := range appRevisions {
+		if revision.ID == revisionId {
+			return int(revision.RevisionNumber), nil
+		}
+	}
+	return 0, errors.New("revision id not found in app revisions")
+}