Преглед на файлове

[POR-1721] Add status footers to porter yaml v2 (#3584)

Feroze Mohideen преди 2 години
родител
ревизия
e34ed7d351

+ 112 - 0
api/server/handlers/porter_app/pod_status.go

@@ -0,0 +1,112 @@
+package porter_app
+
+import (
+	"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"
+	"github.com/porter-dev/porter/api/server/shared/apierrors"
+	"github.com/porter-dev/porter/api/server/shared/config"
+	"github.com/porter-dev/porter/api/types"
+	"github.com/porter-dev/porter/internal/models"
+	"github.com/porter-dev/porter/internal/telemetry"
+	v1 "k8s.io/api/core/v1"
+)
+
+// PodStatusHandler is the handler for GET /apps/pods
+type PodStatusHandler struct {
+	handlers.PorterHandlerReadWriter
+	authz.KubernetesAgentGetter
+}
+
+// NewPodStatusHandler returns a new PodStatusHandler
+func NewPodStatusHandler(
+	config *config.Config,
+	decoderValidator shared.RequestDecoderValidator,
+	writer shared.ResultWriter,
+) *PodStatusHandler {
+	return &PodStatusHandler{
+		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 {
+	DeploymentTargetID string `schema:"deployment_target_id"`
+	Selectors          string `schema:"selectors"`
+}
+
+func (c *PodStatusHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	ctx, span := telemetry.NewSpan(r.Context(), "serve-pod-status")
+	defer span.End()
+
+	request := &PodStatusRequest{}
+	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))
+		return
+	}
+
+	cluster, _ := r.Context().Value(types.ClusterScope).(*models.Cluster)
+	project, _ := r.Context().Value(types.ProjectScope).(*models.Project)
+
+	telemetry.WithAttributes(span, telemetry.AttributeKV{Key: "selectors", Value: request.Selectors})
+
+	if request.DeploymentTargetID == "" {
+		err := telemetry.Error(ctx, span, nil, "must provide deployment target id")
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusBadRequest))
+		return
+	}
+	telemetry.WithAttributes(span, telemetry.AttributeKV{Key: "deployment-target-id", Value: request.DeploymentTargetID})
+
+	deploymentTargetDetailsReq := connect.NewRequest(&porterv1.DeploymentTargetDetailsRequest{
+		ProjectId:          int64(project.ID),
+		DeploymentTargetId: request.DeploymentTargetID,
+	})
+
+	deploymentTargetDetailsResp, err := c.Config().ClusterControlPlaneClient.DeploymentTargetDetails(ctx, deploymentTargetDetailsReq)
+	if err != nil {
+		err := telemetry.Error(ctx, span, err, "error getting deployment target details from cluster control plane client")
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusBadRequest))
+		return
+	}
+
+	if deploymentTargetDetailsResp == nil || deploymentTargetDetailsResp.Msg == nil {
+		err := telemetry.Error(ctx, span, err, "deployment target details resp is nil")
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
+		return
+	}
+
+	if deploymentTargetDetailsResp.Msg.ClusterId != int64(cluster.ID) {
+		err := telemetry.Error(ctx, span, err, "deployment target details resp cluster id does not match cluster id")
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
+		return
+	}
+
+	namespace := deploymentTargetDetailsResp.Msg.Namespace
+	telemetry.WithAttributes(span, telemetry.AttributeKV{Key: "namespace", Value: namespace})
+
+	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
+	}
+
+	pods := []v1.Pod{}
+
+	podsList, err := agent.GetPodsByLabel(request.Selectors, namespace)
+	if err != nil {
+		err = telemetry.Error(ctx, span, err, "unable to get pods by label")
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
+		return
+	}
+
+	pods = append(pods, podsList.Items...)
+
+	c.WriteResult(w, r, pods)
+}

+ 71 - 0
api/server/handlers/porter_app/status.go

@@ -0,0 +1,71 @@
+package porter_app
+
+import (
+	"net/http"
+
+	"github.com/porter-dev/porter/api/server/authz"
+	"github.com/porter-dev/porter/api/server/handlers"
+	"github.com/porter-dev/porter/api/server/shared"
+	"github.com/porter-dev/porter/api/server/shared/apierrors"
+	"github.com/porter-dev/porter/api/server/shared/config"
+	"github.com/porter-dev/porter/api/server/shared/requestutils"
+	"github.com/porter-dev/porter/api/server/shared/websocket"
+	"github.com/porter-dev/porter/api/types"
+	"github.com/porter-dev/porter/internal/models"
+	"github.com/porter-dev/porter/internal/telemetry"
+)
+
+// AppStatusHandler handles the /apps/{kind}/status endpoint
+type AppStatusHandler struct {
+	handlers.PorterHandlerReadWriter
+	authz.KubernetesAgentGetter
+}
+
+// NewAppStatusHandler returns a new AppStatusHandler
+func NewAppStatusHandler(
+	config *config.Config,
+	decoderValidator shared.RequestDecoderValidator,
+	writer shared.ResultWriter,
+) *AppStatusHandler {
+	return &AppStatusHandler{
+		PorterHandlerReadWriter: handlers.NewDefaultPorterHandler(config, decoderValidator, writer),
+		KubernetesAgentGetter:   authz.NewOutOfClusterAgentGetter(config),
+	}
+}
+
+// AppStatusRequest represents the accepted fields on a request to the /apps/{kind}/status endpoint
+type AppStatusRequest struct {
+	Selectors string `schema:"selectors"`
+}
+
+func (c *AppStatusHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	ctx, span := telemetry.NewSpan(r.Context(), "serve-app-logs")
+	defer span.End()
+
+	safeRW := ctx.Value(types.RequestCtxWebsocketKey).(*websocket.WebsocketSafeReadWriter)
+	request := &AppStatusRequest{}
+
+	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))
+		return
+	}
+
+	cluster, _ := ctx.Value(types.ClusterScope).(*models.Cluster)
+
+	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
+	}
+
+	kind, _ := requestutils.GetURLParamString(r, types.URLParamKind)
+
+	err = agent.StreamControllerStatus(kind, request.Selectors, safeRW)
+
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+}

+ 72 - 12
api/server/router/porter_app.go

@@ -47,6 +47,7 @@ func getPorterAppRoutes(
 	factory shared.APIEndpointFactory,
 ) ([]*router.Route, *types.Path) {
 	relPath := "/applications"
+	relPathV2 := "/apps"
 
 	newPath := &types.Path{
 		Parent:       basePath,
@@ -578,7 +579,7 @@ func getPorterAppRoutes(
 			Method: types.HTTPVerbPost,
 			Path: &types.Path{
 				Parent:       basePath,
-				RelativePath: "/apps/parse",
+				RelativePath: fmt.Sprintf("%s/parse", relPathV2),
 			},
 			Scopes: []types.PermissionScope{
 				types.UserScope,
@@ -607,7 +608,7 @@ func getPorterAppRoutes(
 			Method: types.HTTPVerbPost,
 			Path: &types.Path{
 				Parent:       basePath,
-				RelativePath: "/apps/validate",
+				RelativePath: fmt.Sprintf("%s/validate", relPathV2),
 			},
 			Scopes: []types.PermissionScope{
 				types.UserScope,
@@ -636,7 +637,7 @@ func getPorterAppRoutes(
 			Method: types.HTTPVerbPost,
 			Path: &types.Path{
 				Parent:       basePath,
-				RelativePath: "/apps/create",
+				RelativePath: fmt.Sprintf("%s/create", relPathV2),
 			},
 			Scopes: []types.PermissionScope{
 				types.UserScope,
@@ -665,7 +666,7 @@ func getPorterAppRoutes(
 			Method: types.HTTPVerbPost,
 			Path: &types.Path{
 				Parent:       basePath,
-				RelativePath: "/apps/apply",
+				RelativePath: fmt.Sprintf("%s/apply", relPathV2),
 			},
 			Scopes: []types.PermissionScope{
 				types.UserScope,
@@ -723,7 +724,7 @@ func getPorterAppRoutes(
 			Method: types.HTTPVerbGet,
 			Path: &types.Path{
 				Parent:       basePath,
-				RelativePath: fmt.Sprintf("/apps/{%s}/latest", types.URLParamPorterAppName),
+				RelativePath: fmt.Sprintf("%s/{%s}/latest", relPathV2, types.URLParamPorterAppName),
 			},
 			Scopes: []types.PermissionScope{
 				types.UserScope,
@@ -752,7 +753,7 @@ func getPorterAppRoutes(
 			Method: types.HTTPVerbGet,
 			Path: &types.Path{
 				Parent:       basePath,
-				RelativePath: fmt.Sprintf("/apps/{%s}/revisions", types.URLParamPorterAppName),
+				RelativePath: fmt.Sprintf("%s/{%s}/revisions", relPathV2, types.URLParamPorterAppName),
 			},
 			Scopes: []types.PermissionScope{
 				types.UserScope,
@@ -781,7 +782,7 @@ func getPorterAppRoutes(
 			Method: types.HTTPVerbGet,
 			Path: &types.Path{
 				Parent:       basePath,
-				RelativePath: "/apps/revisions",
+				RelativePath: fmt.Sprintf("%s/revisions", relPathV2),
 			},
 			Scopes: []types.PermissionScope{
 				types.UserScope,
@@ -810,7 +811,7 @@ func getPorterAppRoutes(
 			Method: types.HTTPVerbPost,
 			Path: &types.Path{
 				Parent:       basePath,
-				RelativePath: fmt.Sprintf("/apps/{%s}/subdomain", types.URLParamPorterAppName),
+				RelativePath: fmt.Sprintf("%s/{%s}/subdomain", relPathV2, types.URLParamPorterAppName),
 			},
 			Scopes: []types.PermissionScope{
 				types.UserScope,
@@ -839,7 +840,7 @@ func getPorterAppRoutes(
 			Method: types.HTTPVerbGet,
 			Path: &types.Path{
 				Parent:       basePath,
-				RelativePath: fmt.Sprintf("/apps/{%s}/{%s}/predeploy-status", types.URLParamPorterAppName, types.URLParamAppRevisionID),
+				RelativePath: fmt.Sprintf("%s/{%s}/{%s}/predeploy-status", relPathV2, types.URLParamPorterAppName, types.URLParamAppRevisionID),
 			},
 			Scopes: []types.PermissionScope{
 				types.UserScope,
@@ -868,7 +869,7 @@ func getPorterAppRoutes(
 			Method: types.HTTPVerbGet,
 			Path: &types.Path{
 				Parent:       basePath,
-				RelativePath: "/apps/logs",
+				RelativePath: fmt.Sprintf("%s/logs", relPathV2),
 			},
 			Scopes: []types.PermissionScope{
 				types.UserScope,
@@ -897,7 +898,7 @@ func getPorterAppRoutes(
 			Method: types.HTTPVerbGet,
 			Path: &types.Path{
 				Parent:       basePath,
-				RelativePath: "/apps/logs/loki",
+				RelativePath: fmt.Sprintf("%s/logs/loki", relPathV2),
 			},
 			Scopes: []types.PermissionScope{
 				types.UserScope,
@@ -927,7 +928,7 @@ func getPorterAppRoutes(
 			Method: types.HTTPVerbGet,
 			Path: &types.Path{
 				Parent:       basePath,
-				RelativePath: "/apps/metrics",
+				RelativePath: fmt.Sprintf("%s/metrics", relPathV2),
 			},
 			Scopes: []types.PermissionScope{
 				types.UserScope,
@@ -949,6 +950,65 @@ func getPorterAppRoutes(
 		Router:   r,
 	})
 
+	// GET /api/projects/{project_id}/clusters/{cluster_id}/apps/status -> cluster.NewAppStatusHandler
+	appStatusEndpoint := factory.NewAPIEndpoint(
+		&types.APIRequestMetadata{
+			Verb:   types.APIVerbGet,
+			Method: types.HTTPVerbGet,
+			Path: &types.Path{
+				Parent:       basePath,
+				RelativePath: fmt.Sprintf("%s/{%s}/status", relPathV2, types.URLParamKind),
+			},
+			Scopes: []types.PermissionScope{
+				types.UserScope,
+				types.ProjectScope,
+				types.ClusterScope,
+			},
+			IsWebsocket: true,
+		},
+	)
+
+	appStatusHandler := porter_app.NewAppStatusHandler(
+		config,
+		factory.GetDecoderValidator(),
+		factory.GetResultWriter(),
+	)
+
+	routes = append(routes, &router.Route{
+		Endpoint: appStatusEndpoint,
+		Handler:  appStatusHandler,
+		Router:   r,
+	})
+
+	// GET /api/projects/{project_id}/clusters/{cluster_id}/apps/pods -> cluster.NewPodStatusHandler
+	appPodStatusEndpoint := factory.NewAPIEndpoint(
+		&types.APIRequestMetadata{
+			Verb:   types.APIVerbGet,
+			Method: types.HTTPVerbGet,
+			Path: &types.Path{
+				Parent:       basePath,
+				RelativePath: fmt.Sprintf("%s/pods", relPathV2),
+			},
+			Scopes: []types.PermissionScope{
+				types.UserScope,
+				types.ProjectScope,
+				types.ClusterScope,
+			},
+		},
+	)
+
+	appPodStatusHandler := porter_app.NewPodStatusHandler(
+		config,
+		factory.GetDecoderValidator(),
+		factory.GetResultWriter(),
+	)
+
+	routes = append(routes, &router.Route{
+		Endpoint: appPodStatusEndpoint,
+		Handler:  appPodStatusHandler,
+		Router:   r,
+	})
+
 	// POST /api/projects/{project_id}/clusters/{cluster_id}/apps/{porter_app_name}/revisions/{app_revision_id} -> porter_app.NewUpdateAppRevisionStatusHandler
 	updateAppRevisionStatusEndpoint := factory.NewAPIEndpoint(
 		&types.APIRequestMetadata{

+ 215 - 0
dashboard/src/lib/hooks/useAppStatus.ts

@@ -0,0 +1,215 @@
+import _ from "lodash";
+import { useEffect, useMemo, useState } from "react";
+import api from "shared/api";
+import { NewWebsocketOptions, useWebsockets } from "shared/hooks/useWebsockets";
+import { useRevisionIdToNumber } from "./useRevisionList";
+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 = useRevisionIdToNumber(appName, deploymentTargetId);
+
+    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);
+    };
+
+    const updatePods = async (serviceName: string) => {
+        const selectors = `porter.run/service-name=${serviceName},porter.run/deployment-target-id=${deploymentTargetId}`;
+
+        try {
+            const res = await api.appPodStatus(
+                "<token>",
+                {
+                    deployment_target_id: deploymentTargetId,
+                    selectors,
+                },
+                {
+                    id: projectId,
+                    cluster_id: clusterId,
+                }
+            );
+            // 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
+        }
+    };
+
+    useEffect(() => {
+        Promise.all(serviceNames.map(updatePods));
+        for (let serviceName of serviceNames) {
+            setupWebsocket(serviceName);
+        }
+        return () => closeAllWebsockets();
+    }, [projectId, clusterId, deploymentTargetId, appName, JSON.stringify(revisionIdToNumber)]);
+
+    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} replica${replicaSet.length === 1 ? "" : "s"} ${replicaSet.length === 1 ? "is" : "are"
+                    } failing to run Version ${version}`;
+            } else if (
+                i > 0 && replicaSetArray[i - 1].every(p => !p.isFailing)
+            ) {
+                status = "spinningDown";
+                message = `${replicaSet.length} replica${replicaSet.length === 1 ? "" : "s"} ${replicaSet.length === 1 ? "is" : "are"
+                    } still running at Version ${version}. Attempting to spin down...`;
+            } else {
+                status = "running";
+                message = `${replicaSet.length} replica${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 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)]);
+
+    return {
+        serviceVersionStatus,
+    };
+};

+ 31 - 27
dashboard/src/lib/hooks/useRevisionList.ts

@@ -3,12 +3,12 @@ import { useContext, useEffect, useState } from "react";
 import { Context } from "shared/Context";
 import api from "shared/api";
 import { z } from "zod";
-import {AppRevision, appRevisionValidator} from "../revisions/types";
-import {useLatestRevision} from "../../main/home/app-dashboard/app-view/LatestRevisionContext";
+import { AppRevision, appRevisionValidator } from "../revisions/types";
+import { useLatestRevision } from "../../main/home/app-dashboard/app-view/LatestRevisionContext";
 
 export function useRevisionList(appName: string, deploymentTargetId: string) {
   const { currentProject, currentCluster } = useContext(Context);
-  const {latestRevision} = useLatestRevision();
+  const { latestRevision } = useLatestRevision();
 
   const [
     revisionList,
@@ -19,29 +19,33 @@ export function useRevisionList(appName: string, deploymentTargetId: string) {
     return [];
   }
 
-  const {data} = useQuery(
-      ["listAppRevisions", currentProject.id, currentCluster.id, appName, deploymentTargetId, latestRevision],
-      async () => {
-        const res = await api.listAppRevisions(
-            "<token>",
-            {
-              deployment_target_id: deploymentTargetId,
-            },
-            {
-              project_id: currentProject.id,
-              cluster_id: currentCluster.id,
-              porter_app_name: appName,
-            }
-        );
+  const { data } = useQuery(
+    ["listAppRevisions", currentProject.id, currentCluster.id, appName, deploymentTargetId, latestRevision],
+    async () => {
+      const res = await api.listAppRevisions(
+        "<token>",
+        {
+          deployment_target_id: deploymentTargetId,
+        },
+        {
+          project_id: currentProject.id,
+          cluster_id: currentCluster.id,
+          porter_app_name: appName,
+        }
+      );
 
-        const revisions = await z
-            .object({
-              app_revisions: z.array(appRevisionValidator),
-            })
-            .parseAsync(res.data);
+      const revisions = await z
+        .object({
+          app_revisions: z.array(appRevisionValidator),
+        })
+        .parseAsync(res.data);
 
-        return revisions;
-      }
+      return revisions;
+    },
+    {
+      enabled: !!currentProject && !!currentCluster,
+      refetchInterval: 5000,
+    }
   );
 
   useEffect(() => {
@@ -54,10 +58,10 @@ export function useRevisionList(appName: string, deploymentTargetId: string) {
 }
 
 export function useRevisionIdToNumber(appName: string, deploymentTargetId: string) {
-    const revisionList = useRevisionList(appName, deploymentTargetId);
-    const revisionIdToNumber: Record<string, number> = Object.fromEntries(revisionList.map(r => ([r.id, r.revision_number ])))
+  const revisionList = useRevisionList(appName, deploymentTargetId);
+  const revisionIdToNumber: Record<string, number> = Object.fromEntries(revisionList.map(r => ([r.id, r.revision_number])))
 
-    return revisionIdToNumber;
+  return revisionIdToNumber;
 }
 
 export function useLatestRevisionNumber(appName: string, deploymentTargetId: string) {

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

@@ -2,7 +2,7 @@ import { PorterApp } from "@porter-dev/api-contracts";
 import Spacer from "components/porter/Spacer";
 import Text from "components/porter/Text";
 import { PorterAppFormData } from "lib/porter-apps";
-import React, { useMemo } from "react";
+import React, { useEffect, useMemo } from "react";
 import { useFormContext, useFormState } from "react-hook-form";
 import ServiceList from "../../validate-apply/services-settings/ServiceList";
 import {
@@ -12,10 +12,19 @@ import {
 import Error from "components/porter/Error";
 import Button from "components/porter/Button";
 import { useLatestRevision } from "../LatestRevisionContext";
+import { useAppStatus } from "lib/hooks/useAppStatus";
 
 const Overview: React.FC = () => {
   const { formState } = useFormContext<PorterAppFormData>();
-  const { porterApp, latestProto, latestRevision } = useLatestRevision();
+  const { porterApp, latestProto, latestRevision, projectId, clusterId, deploymentTargetId } = useLatestRevision();
+
+  const { serviceVersionStatus } = useAppStatus({
+    projectId,
+    clusterId,
+    serviceNames: Object.keys(latestProto.services).filter(name => latestProto.services[name].config.case !== "jobConfig"),
+    deploymentTargetId,
+    appName: latestProto.name,
+  });
 
   const buttonStatus = useMemo(() => {
     if (formState.isSubmitting) {
@@ -56,6 +65,7 @@ const Overview: React.FC = () => {
         addNewText={"Add a new service"}
         fieldArrayName={"app.services"}
         existingServiceNames={Object.keys(latestProto.services)}
+        serviceVersionStatus={serviceVersionStatus}
       />
       <Spacer y={0.75} />
       <Button

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

@@ -1,4 +1,4 @@
-import React, { useCallback, useContext, useEffect, useState } from "react";
+import React, { useCallback, useContext, useEffect, useMemo, useState } from "react";
 import AnimateHeight, { Height } from "react-animate-height";
 import styled from "styled-components";
 import _ from "lodash";
@@ -21,21 +21,23 @@ import { UseFieldArrayUpdate } from "react-hook-form";
 import { PorterAppFormData } from "lib/porter-apps";
 import { match } from "ts-pattern";
 import useResizeObserver from "lib/hooks/useResizeObserver";
+import { PorterAppVersionStatus } from "lib/hooks/useAppStatus";
+import ServiceStatusFooter from "./ServiceStatusFooter";
 
 interface ServiceProps {
   index: number;
   service: ClientService;
-  chart?: any;
   update: UseFieldArrayUpdate<PorterAppFormData, "app.services" | "app.predeploy">;
   remove: (index: number) => void;
+  status?: PorterAppVersionStatus[];
 }
 
 const ServiceContainer: React.FC<ServiceProps> = ({
   index,
   service,
-  chart,
   update,
   remove,
+  status,
 }) => {
   const [height, setHeight] = useState<Height>(service.expanded ? "auto" : 0);
 
@@ -79,22 +81,24 @@ const ServiceContainer: React.FC<ServiceProps> = ({
     }
     var instanceType = "";
 
-    if (service) {
-      //first check if there is a nodeSelector for the given application (Can be null)
-      if (
-        chart?.config?.[`${service.name.value}-${service.config.type}`]
-          ?.nodeSelector?.["beta.kubernetes.io/instance-type"]
-      ) {
-        instanceType =
-          chart?.config?.[`${service.name.value}-${service.config.type}`]
-            ?.nodeSelector?.["beta.kubernetes.io/instance-type"];
-        const [instanceClass, instanceSize] = instanceType.split(".");
-        const currentInstance =
-          AWS_INSTANCE_LIMITS[instanceClass][instanceSize];
-        setMaxCPU(currentInstance.vCPU * UPPER_BOUND);
-        setMaxRAM(currentInstance.RAM * UPPER_BOUND);
-      }
-    }
+
+    // need to fix the below to not use chart
+    // if (service) {
+    //   //first check if there is a nodeSelector for the given application (Can be null)
+    //   if (
+    //     chart?.config?.[`${service.name.value}-${service.config.type}`]
+    //       ?.nodeSelector?.["beta.kubernetes.io/instance-type"]
+    //   ) {
+    //     instanceType =
+    //       chart?.config?.[`${service.name.value}-${service.config.type}`]
+    //         ?.nodeSelector?.["beta.kubernetes.io/instance-type"];
+    //     const [instanceClass, instanceSize] = instanceType.split(".");
+    //     const currentInstance =
+    //       AWS_INSTANCE_LIMITS[instanceClass][instanceSize];
+    //     setMaxCPU(currentInstance.vCPU * UPPER_BOUND);
+    //     setMaxRAM(currentInstance.RAM * UPPER_BOUND);
+    //   }
+    // }
     //Query the given nodes if no instance type is specified
     if (instanceType == "") {
       api
@@ -185,13 +189,6 @@ const ServiceContainer: React.FC<ServiceProps> = ({
     }
   };
 
-  const getHasBuiltImage = () => {
-    if (!chart?.chart?.values) {
-      return false;
-    }
-    return !_.isEmpty((Object.values(chart.chart.values)[0] as any)?.global);
-  };
-
   return (
     <>
       <ServiceHeader
@@ -202,8 +199,7 @@ const ServiceContainer: React.FC<ServiceProps> = ({
             expanded: !service.expanded,
           });
         }}
-        chart={chart}
-        bordersRounded={!getHasBuiltImage() && !service.expanded}
+        bordersRounded={!status && !service.expanded}
       >
         <ServiceTitle>
           <ActionButton>
@@ -234,23 +230,17 @@ const ServiceContainer: React.FC<ServiceProps> = ({
         {height !== 0 && (
           <StyledSourceBox
             showExpanded={service.expanded}
-            chart={chart}
-            hasFooter={chart && service && getHasBuiltImage()}
+            hasFooter={status != null}
           >
             {renderTabs(service)}
           </StyledSourceBox>
         )}
       </AnimateHeight>
-      {chart &&
-        service &&
-        // Check if has built image
-        getHasBuiltImage() && (
-          <StatusFooter
-            setExpandedJob={() => { }}
-            chart={chart}
-            service={service}
-          />
-        )}
+      {status && (
+        <ServiceStatusFooter
+          status={status}
+        />
+      )}
       <Spacer y={0.5} />
     </>
   );
@@ -264,7 +254,6 @@ const ServiceTitle = styled.div`
 `;
 
 const StyledSourceBox = styled.div<{
-  chart: any;
   showExpanded?: boolean;
   hasFooter?: boolean;
 }>`
@@ -303,7 +292,6 @@ const ActionButton = styled.button`
 `;
 
 const ServiceHeader = styled.div<{
-  chart: any;
   showExpanded?: boolean;
   bordersRounded?: boolean;
 }>`
@@ -330,7 +318,7 @@ const ServiceHeader = styled.div<{
     cursor: pointer;
     border-radius: 20px;
     margin-left: -10px;
-    transform: ${(props: { showExpanded?: boolean; chart: any }) =>
+    transform: ${(props: { showExpanded?: boolean; }) =>
     props.showExpanded ? "" : "rotate(-90deg)"};
   }
 `;

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

@@ -1,4 +1,4 @@
-import React, { useMemo, useState } from "react";
+import React, { useEffect, useMemo, useState } from "react";
 import ServiceContainer from "./ServiceContainer";
 import styled from "styled-components";
 import Spacer from "components/porter/Spacer";
@@ -26,6 +26,7 @@ import {
   useFormContext,
 } from "react-hook-form";
 import { ControlledInput } from "components/porter/ControlledInput";
+import { PorterAppVersionStatus } from "lib/hooks/useAppStatus";
 import { zodResolver } from "@hookform/resolvers/zod";
 
 const addServiceFormValidator = z.object({
@@ -46,6 +47,7 @@ type ServiceListProps = {
   isPredeploy?: boolean;
   existingServiceNames?: string[];
   fieldArrayName: "app.services" | "app.predeploy";
+  serviceVersionStatus?: Record<string, PorterAppVersionStatus[]>;
 };
 
 const ServiceList: React.FC<ServiceListProps> = ({
@@ -54,6 +56,7 @@ const ServiceList: React.FC<ServiceListProps> = ({
   isPredeploy = false,
   existingServiceNames = [],
   fieldArrayName,
+  serviceVersionStatus,
 }) => {
   // top level app form
   const { control: appControl } = useFormContext<PorterAppFormData>();
@@ -174,6 +177,7 @@ const ServiceList: React.FC<ServiceListProps> = ({
                 service={svc}
                 update={update}
                 remove={onRemove}
+                status={serviceVersionStatus?.[svc.name.value]}
               />
             ) : null;
           })}

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

@@ -0,0 +1,255 @@
+import React, { useState } from "react";
+import styled from "styled-components";
+
+import Text from "components/porter/Text";
+import Container from "components/porter/Container";
+import Button from "components/porter/Button";
+
+import { RouteComponentProps } from "react-router";
+import AnimateHeight, { Height } from "react-animate-height";
+import _ from "lodash";
+import Link from "components/porter/Link";
+import { PorterAppVersionStatus } from "lib/hooks/useAppStatus";
+import { match } from "ts-pattern";
+
+interface ServiceStatusFooterProps {
+    status: PorterAppVersionStatus[];
+}
+const ServiceStatusFooter: React.FC<ServiceStatusFooterProps> = ({
+    status,
+}) => {
+    const [expanded, setExpanded] = useState<boolean>(false);
+    const [height, setHeight] = useState<Height>(0);
+
+    // if (service.type === "job") {
+    //     return (
+    //         <StyledStatusFooter>
+    //             {service.type === "job" && (
+    //                 <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/${chart.name}/job-history?service=${service.name}`}>
+    //                         <Button
+    //                             onClick={() => { }}
+    //                             height="30px"
+    //                             width="87px"
+    //                             color="#ffffff11"
+    //                             withBorder
+    //                         >
+    //                             <I className="material-icons">open_in_new</I>
+    //                             History
+    //                         </Button>
+    //                     </Link>
+    //                 </Container>
+    //             )}
+    //         </StyledStatusFooter>
+    //     );
+    // }
+
+    return (
+        <>
+            {status.map((versionStatus, i) => {
+                return (
+                    <>
+                        <StyledStatusFooterTop key={i} 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>
+                        )}
+                    </>
+                );
+            })}
+        </>
+    );
+};
+
+export default ServiceStatusFooter;
+
+const StatusDot = styled.div<{ color?: string }>`
+  min-width: 7px;
+  max-width: 7px;
+  height: 7px;
+  border-radius: 50%;
+  margin-right: 10px;
+  background: ${(props) => props.color || "#38a88a"};
+
+  box-shadow: 0 0 0 0 rgba(0, 0, 0, 1);
+  transform: scale(1);
+  animation: pulse 2s infinite;
+  @keyframes pulse {
+    0% {
+      transform: scale(0.95);
+      box-shadow: 0 0 0 0 rgba(0, 0, 0, 0.7);
+    }
+
+    70% {
+      transform: scale(1);
+      box-shadow: 0 0 0 10px rgba(0, 0, 0, 0);
+    }
+
+    100% {
+      transform: scale(0.95);
+      box-shadow: 0 0 0 0 rgba(0, 0, 0, 0);
+    }
+  }
+`;
+
+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;
+`;
+
+const StyledStatusFooter = styled.div`
+  width: 100%;
+  padding: 10px 15px;
+  background: ${(props) => props.theme.fg2};
+  border-bottom-left-radius: 5px;
+  border-bottom-right-radius: 5px;
+  border: 1px solid #494b4f;
+  border-top: 0;
+  overflow: hidden;
+  display: flex;
+  align-items: stretch;
+  flex-direction: row;
+  animation: fadeIn 0.5s;
+  @keyframes fadeIn {
+    from {
+      opacity: 0;
+    }
+    to {
+      opacity: 1;
+    }
+  }
+`;
+
+const StyledStatusFooterTop = styled(StyledStatusFooter) <{
+    expanded: boolean;
+}>`
+  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<{
+    row: boolean;
+    spaced: boolean;
+}>`
+  display: ${(props) => (props.row ? "flex" : "block")};
+  align-items: center;
+  justify-content: ${(props) =>
+        props.spaced ? "space-between" : "flex-start"};
+  width: 100%;
+`;

+ 11 - 0
dashboard/src/shared/api.tsx

@@ -300,6 +300,16 @@ const appLogs = baseApi<
     `/api/projects/${project_id}/clusters/${cluster_id}/apps/logs`
 );
 
+const appPodStatus = baseApi<
+  {
+    deployment_target_id: string;
+    selectors: string;
+  },
+  { id: number; cluster_id: number }
+>("GET", (pathParams) => {
+  return `/api/projects/${pathParams.id}/clusters/${pathParams.cluster_id}/apps/pods`;
+});
+
 const getFeedEvents = baseApi<
   {},
   {
@@ -3013,6 +3023,7 @@ export default {
   createSecretAndOpenGitHubPullRequest,
   getLogsWithinTimeRange,
   appLogs,
+  appPodStatus,
   getFeedEvents,
   updateStackStep,
   // -----------------------------------

+ 1 - 1
dashboard/src/shared/hooks/useWebsockets.ts

@@ -49,7 +49,7 @@ export const useWebsockets = () => {
 
     const url = `${protocol}://${window.location.host}${apiEndpoint}`;
 
-    const mockFunction = () => {};
+    const mockFunction = () => { };
 
     const wsConfig: WebsocketConfig = {
       url,