From e34ed7d351f65f6ea4d2146496659470c70b695b Mon Sep 17 00:00:00 2001 From: Feroze Mohideen Date: Tue, 19 Sep 2023 10:59:39 -0400 Subject: [PATCH] [POR-1721] Add status footers to porter yaml v2 (#3584) --- api/server/handlers/porter_app/pod_status.go | 112 ++++++++ api/server/handlers/porter_app/status.go | 71 +++++ api/server/router/porter_app.go | 84 +++++- dashboard/src/lib/hooks/useAppStatus.ts | 215 +++++++++++++++ dashboard/src/lib/hooks/useRevisionList.ts | 58 ++-- .../app-dashboard/app-view/tabs/Overview.tsx | 14 +- .../services-settings/ServiceContainer.tsx | 74 +++-- .../services-settings/ServiceList.tsx | 6 +- .../services-settings/ServiceStatusFooter.tsx | 255 ++++++++++++++++++ dashboard/src/shared/api.tsx | 11 + dashboard/src/shared/hooks/useWebsockets.ts | 2 +- 11 files changed, 816 insertions(+), 86 deletions(-) create mode 100644 api/server/handlers/porter_app/pod_status.go create mode 100644 api/server/handlers/porter_app/status.go create mode 100644 dashboard/src/lib/hooks/useAppStatus.ts create mode 100644 dashboard/src/main/home/app-dashboard/validate-apply/services-settings/ServiceStatusFooter.tsx diff --git a/api/server/handlers/porter_app/pod_status.go b/api/server/handlers/porter_app/pod_status.go new file mode 100644 index 0000000000..150f681083 --- /dev/null +++ b/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) +} diff --git a/api/server/handlers/porter_app/status.go b/api/server/handlers/porter_app/status.go new file mode 100644 index 0000000000..1691e88037 --- /dev/null +++ b/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 + } +} diff --git a/api/server/router/porter_app.go b/api/server/router/porter_app.go index 77c7c62c6f..4f6dd05bd8 100644 --- a/api/server/router/porter_app.go +++ b/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{ diff --git a/dashboard/src/lib/hooks/useAppStatus.ts b/dashboard/src/lib/hooks/useAppStatus.ts new file mode 100644 index 0000000000..c4e66809fc --- /dev/null +++ b/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>({}); + + 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( + "", + { + 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 = useMemo(() => { + const serviceReplicaSetMap = Object.fromEntries(Object.keys(servicePodMap).map((serviceName) => { + const pods = servicePodMap[serviceName]; + const replicaSetMap = _.sortBy(pods, ["helmRevision"]) + .reverse() + .reduce(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, + }; +}; \ No newline at end of file diff --git a/dashboard/src/lib/hooks/useRevisionList.ts b/dashboard/src/lib/hooks/useRevisionList.ts index 3541ab8452..dc0b0dfa34 100644 --- a/dashboard/src/lib/hooks/useRevisionList.ts +++ b/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( - "", - { - 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( + "", + { + 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 = Object.fromEntries(revisionList.map(r => ([r.id, r.revision_number ]))) + const revisionList = useRevisionList(appName, deploymentTargetId); + const revisionIdToNumber: Record = Object.fromEntries(revisionList.map(r => ([r.id, r.revision_number]))) - return revisionIdToNumber; + return revisionIdToNumber; } export function useLatestRevisionNumber(appName: string, deploymentTargetId: string) { diff --git a/dashboard/src/main/home/app-dashboard/app-view/tabs/Overview.tsx b/dashboard/src/main/home/app-dashboard/app-view/tabs/Overview.tsx index 975687baad..4f7dbae317 100644 --- a/dashboard/src/main/home/app-dashboard/app-view/tabs/Overview.tsx +++ b/dashboard/src/main/home/app-dashboard/app-view/tabs/Overview.tsx @@ -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(); - 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} /> + // + // + // )} + // + // ); + // } + + return ( + <> + {status.map((versionStatus, i) => { + return ( + <> + + + {match(versionStatus) + .with({ status: "failing" }, (vs) => { + return ( + <> + + + + {vs.message} + + + {vs.crashLoopReason && + + } + + ) + }) + .with({ status: "spinningDown" }, (vs) => { + return ( + + + + {vs.message} + + + ) + }) + .with({ status: "running" }, (vs) => { + return ( + + + + {vs.message} + + + ) + }) + .exhaustive() + } + + + {versionStatus.crashLoopReason && ( + + + + {versionStatus.crashLoopReason} + + + + )} + + ); + })} + + ); +}; + +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%; +`; diff --git a/dashboard/src/shared/api.tsx b/dashboard/src/shared/api.tsx index 813fd0197b..0f2c83b960 100644 --- a/dashboard/src/shared/api.tsx +++ b/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, // ----------------------------------- diff --git a/dashboard/src/shared/hooks/useWebsockets.ts b/dashboard/src/shared/hooks/useWebsockets.ts index c89dd01fb2..52e15523ed 100644 --- a/dashboard/src/shared/hooks/useWebsockets.ts +++ b/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,