Răsfoiți Sursa

[POR-1653] enable metrics on apply v2 apps (#3509)

Co-authored-by: David Townley <davidtownley@Davids-MacBook-Air.local>
d-g-town 2 ani în urmă
părinte
comite
0cf003fed1

+ 156 - 0
api/server/handlers/porter_app/app_metrics.go

@@ -0,0 +1,156 @@
+package porter_app
+
+import (
+	"net/http"
+
+	"github.com/porter-dev/porter/internal/kubernetes/prometheus"
+
+	"connectrpc.com/connect"
+
+	porterv1 "github.com/porter-dev/api-contracts/generated/go/porter/v1"
+	"github.com/porter-dev/porter/internal/telemetry"
+
+	"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"
+)
+
+// AppMetricsHandler handles the /apps/metrics endpoint
+type AppMetricsHandler struct {
+	handlers.PorterHandlerReadWriter
+	authz.KubernetesAgentGetter
+}
+
+// NewAppMetricsHandler returns a new AppMetricsHandler
+func NewAppMetricsHandler(
+	config *config.Config,
+	decoderValidator shared.RequestDecoderValidator,
+	writer shared.ResultWriter,
+) *AppMetricsHandler {
+	return &AppMetricsHandler{
+		PorterHandlerReadWriter: handlers.NewDefaultPorterHandler(config, decoderValidator, writer),
+		KubernetesAgentGetter:   authz.NewOutOfClusterAgentGetter(config),
+	}
+}
+
+// MetricsRequest is the expected request body for the /apps/metrics endpoint
+type MetricsRequest struct {
+	// Deployment target of the app to query for metrics
+	DeploymentTargetID string `schema:"deployment_target_id"`
+
+	// Below is just a copy of prometheus.QueryOpts, other than namespace
+	// the name of the metric being queried for
+	Metric    string   `schema:"metric"`
+	ShouldSum bool     `schema:"shouldsum"`
+	Kind      string   `schema:"kind"`
+	PodList   []string `schema:"pods"`
+	Name      string   `schema:"name"`
+	// start time (in unix timestamp) for prometheus results
+	StartRange uint `schema:"startrange"`
+	// end time time (in unix timestamp) for prometheus results
+	EndRange   uint    `schema:"endrange"`
+	Resolution string  `schema:"resolution"`
+	Percentile float64 `schema:"percentile"`
+}
+
+// ServeHTTP returns metrics for a given app in the provided deployment target
+func (c *AppMetricsHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	ctx, span := telemetry.NewSpan(r.Context(), "serve-app-metrics")
+	defer span.End()
+	r = r.Clone(ctx)
+	project, _ := ctx.Value(types.ProjectScope).(*models.Project)
+	cluster, _ := ctx.Value(types.ClusterScope).(*models.Cluster)
+
+	request := &MetricsRequest{}
+
+	if ok := c.DecodeAndValidate(w, r, request); !ok {
+		err := telemetry.Error(ctx, span, nil, "error decoding request")
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusBadRequest))
+		return
+	}
+
+	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, "error getting k8s agent")
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
+		return
+	}
+
+	// get prometheus service
+	promSvc, found, err := prometheus.GetPrometheusService(agent.Clientset)
+	if err != nil || !found {
+		err = telemetry.Error(ctx, span, err, "error getting prometheus service")
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
+		return
+	}
+
+	telemetry.WithAttributes(span,
+		telemetry.AttributeKV{Key: "metric", Value: request.Metric},
+		telemetry.AttributeKV{Key: "shouldsum", Value: request.ShouldSum},
+		telemetry.AttributeKV{Key: "kind", Value: request.Kind},
+		telemetry.AttributeKV{Key: "name", Value: request.Name},
+		telemetry.AttributeKV{Key: "start-range", Value: request.StartRange},
+		telemetry.AttributeKV{Key: "end-range", Value: request.EndRange},
+		telemetry.AttributeKV{Key: "resolution", Value: request.Resolution},
+		telemetry.AttributeKV{Key: "percentile", Value: request.Percentile},
+	)
+
+	queryOpts := &prometheus.QueryOpts{
+		Metric:     request.Metric,
+		ShouldSum:  request.ShouldSum,
+		Kind:       request.Kind,
+		Name:       request.Name,
+		Namespace:  namespace,
+		StartRange: request.StartRange,
+		EndRange:   request.EndRange,
+		Resolution: request.Resolution,
+		Percentile: request.Percentile,
+	}
+
+	rawQuery, err := prometheus.QueryPrometheus(agent.Clientset, promSvc, queryOpts)
+	if err != nil {
+		err = telemetry.Error(ctx, span, err, "error querying prometheus")
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
+		return
+	}
+
+	c.WriteResult(w, r, rawQuery)
+}

+ 29 - 0
api/server/router/porter_app.go

@@ -891,5 +891,34 @@ func getPorterAppRoutes(
 		Router:   r,
 	})
 
+	// GET /api/projects/{project_id}/clusters/{cluster_id}/apps/metrics -> cluster.NewGetPodMetricsHandler
+	appMetricsEndpoint := factory.NewAPIEndpoint(
+		&types.APIRequestMetadata{
+			Verb:   types.APIVerbGet,
+			Method: types.HTTPVerbGet,
+			Path: &types.Path{
+				Parent:       basePath,
+				RelativePath: "/apps/metrics",
+			},
+			Scopes: []types.PermissionScope{
+				types.UserScope,
+				types.ProjectScope,
+				types.ClusterScope,
+			},
+		},
+	)
+
+	appMetricsHandler := porter_app.NewAppMetricsHandler(
+		config,
+		factory.GetDecoderValidator(),
+		factory.GetResultWriter(),
+	)
+
+	routes = append(routes, &router.Route{
+		Endpoint: appMetricsEndpoint,
+		Handler:  appMetricsHandler,
+		Router:   r,
+	})
+
 	return routes, newPath
 }

+ 7 - 7
dashboard/package-lock.json

@@ -13,7 +13,7 @@
         "@loadable/component": "^5.15.2",
         "@material-ui/core": "^4.11.3",
         "@material-ui/lab": "^4.0.0-alpha.61",
-        "@porter-dev/api-contracts": "^0.0.95",
+        "@porter-dev/api-contracts": "^0.0.99",
         "@react-spring/web": "^9.6.1",
         "@sentry/react": "^6.13.2",
         "@sentry/tracing": "^6.13.2",
@@ -2454,9 +2454,9 @@
       }
     },
     "node_modules/@porter-dev/api-contracts": {
-      "version": "0.0.95",
-      "resolved": "https://registry.npmjs.org/@porter-dev/api-contracts/-/api-contracts-0.0.95.tgz",
-      "integrity": "sha512-nwbpfyv5qvhjKdHU7fnR3S6+E9ijwm3/OtZ+WCItn1JZNrDZtb2x047AkBndVU6NKDtUnxHYGYwQJo5spAw7cQ==",
+      "version": "0.0.99",
+      "resolved": "https://registry.npmjs.org/@porter-dev/api-contracts/-/api-contracts-0.0.99.tgz",
+      "integrity": "sha512-boropiMEHIXJLTKxmO6689GhIMiTC95JMkL1ouFxn2mkiT6DPcJ08UfD5tKohUMYGhgQNJceBQ1biPVjn5nqJQ==",
       "dependencies": {
         "@bufbuild/protobuf": "^1.1.0"
       }
@@ -16943,9 +16943,9 @@
       "integrity": "sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A=="
     },
     "@porter-dev/api-contracts": {
-      "version": "0.0.95",
-      "resolved": "https://registry.npmjs.org/@porter-dev/api-contracts/-/api-contracts-0.0.95.tgz",
-      "integrity": "sha512-nwbpfyv5qvhjKdHU7fnR3S6+E9ijwm3/OtZ+WCItn1JZNrDZtb2x047AkBndVU6NKDtUnxHYGYwQJo5spAw7cQ==",
+      "version": "0.0.99",
+      "resolved": "https://registry.npmjs.org/@porter-dev/api-contracts/-/api-contracts-0.0.99.tgz",
+      "integrity": "sha512-boropiMEHIXJLTKxmO6689GhIMiTC95JMkL1ouFxn2mkiT6DPcJ08UfD5tKohUMYGhgQNJceBQ1biPVjn5nqJQ==",
       "requires": {
         "@bufbuild/protobuf": "^1.1.0"
       }

+ 1 - 1
dashboard/package.json

@@ -8,7 +8,7 @@
     "@loadable/component": "^5.15.2",
     "@material-ui/core": "^4.11.3",
     "@material-ui/lab": "^4.0.0-alpha.61",
-    "@porter-dev/api-contracts": "^0.0.95",
+    "@porter-dev/api-contracts": "^0.0.99",
     "@react-spring/web": "^9.6.1",
     "@sentry/react": "^6.13.2",
     "@sentry/tracing": "^6.13.2",

+ 4 - 1
dashboard/src/main/home/app-dashboard/app-view/AppDataContainer.tsx

@@ -26,6 +26,7 @@ import Button from "components/porter/Button";
 import Icon from "components/porter/Icon";
 import save from "assets/save-01.svg";
 import LogsTab from "./tabs/LogsTab";
+import MetricsTab from "./tabs/MetricsTab";
 
 // commented out tabs are not yet implemented
 // will be included as support is available based on data from app revisions rather than helm releases
@@ -34,7 +35,7 @@ const validTabs = [
   // "events",
   "overview",
   "logs",
-  // "metrics",
+  "metrics",
   // "debug",
   "environment",
   "build-settings",
@@ -247,6 +248,7 @@ const AppDataContainer: React.FC<AppDataContainerProps> = ({ tabParam }) => {
           options={[
             { label: "Overview", value: "overview" },
             { label: "Logs", value: "logs" },
+            { label: "Metrics", value: "metrics" },
             { label: "Environment", value: "environment" },
             ...(latestProto.build
               ? [
@@ -275,6 +277,7 @@ const AppDataContainer: React.FC<AppDataContainerProps> = ({ tabParam }) => {
           .with("environment", () => <Environment />)
           .with("settings", () => <Settings />)
           .with("logs", () => <LogsTab />)
+          .with("metrics", () => <MetricsTab />)
           .otherwise(() => null)}
         <Spacer y={2} />
       </form>

+ 23 - 0
dashboard/src/main/home/app-dashboard/app-view/tabs/MetricsTab.tsx

@@ -0,0 +1,23 @@
+import React from "react";
+import { useLatestRevision } from "../LatestRevisionContext";
+import MetricsSection from "../../validate-apply/metrics/MetricsSection";
+
+const MetricsTab: React.FC = () => {
+    const { projectId, clusterId, latestProto , deploymentTargetId} = useLatestRevision();
+
+    const appName = latestProto.name
+
+    return (
+        <>
+            <MetricsSection
+                projectId={projectId}
+                clusterId={clusterId}
+                appName={appName}
+                services={latestProto.services}
+                deploymentTargetId={deploymentTargetId}
+            />
+        </>
+    );
+};
+
+export default MetricsTab;

+ 364 - 0
dashboard/src/main/home/app-dashboard/validate-apply/metrics/MetricsSection.tsx

@@ -0,0 +1,364 @@
+import React, {useEffect, useMemo, useState} from "react";
+import styled from "styled-components";
+
+import api from "shared/api";
+
+import TabSelector from "components/TabSelector";
+import SelectRow from "components/form-components/SelectRow";
+import { MetricNormalizer, resolutions, secondsBeforeNow } from "../../expanded-app/metrics/utils";
+import { Metric, MetricType, NginxStatusMetric } from "../../expanded-app/metrics/types";
+import { match } from "ts-pattern";
+import { AvailableMetrics, NormalizedMetricsData } from "main/home/cluster-dashboard/expanded-chart/metrics/types";
+import MetricsChart from "../../expanded-app/metrics/MetricsChart";
+import { useQuery } from "@tanstack/react-query";
+import Loading from "components/Loading";
+import CheckboxRow from "components/CheckboxRow";
+import {PorterApp} from "@porter-dev/api-contracts";
+
+type PropsType = {
+    projectId: number;
+    clusterId: number;
+    appName: string;
+    services: PorterApp["services"];
+    deploymentTargetId: string;
+};
+
+type ServiceOption = {
+    label: string;
+    value: string;
+}
+
+const MetricsSection: React.FunctionComponent<PropsType> = ({
+    projectId,
+    clusterId,
+    appName,
+    services,
+    deploymentTargetId,
+}) => {
+  const [selectedServiceName, setSelectedServiceName] = useState<string>("");
+  const [selectedRange, setSelectedRange] = useState("1H");
+  const [showAutoscalingThresholds, setShowAutoscalingThresholds] = useState(true);
+
+
+  const serviceOptions: ServiceOption[] = useMemo(() => {
+      return Object.keys(services).map((name) => {
+          return {
+              label: name,
+              value: name,
+          };
+      });
+  }, [services]);
+
+    useEffect(() => {
+        if (serviceOptions.length > 0) {
+            setSelectedServiceName(serviceOptions[0].value)
+        }
+    }, []);
+
+    const [serviceName, serviceKind, metricTypes, isHpaEnabled] = useMemo(() => {
+        if (selectedServiceName === "") {
+            return ["", "", [], false]
+        }
+
+        const service = services[selectedServiceName]
+
+        const serviceName = service.absoluteName === "" ? (appName + "-" + selectedServiceName) : service.absoluteName
+
+        let serviceKind = ""
+        const metricTypes: MetricType[] = ["cpu", "memory"];
+        let isHpaEnabled = false
+
+        if (service.config.case === "webConfig") {
+            serviceKind = "web"
+            metricTypes.push("network");
+            if (service.config.value.autoscaling != null && service.config.value.autoscaling.enabled) {
+                isHpaEnabled = true
+            }
+            if (!service.config.value.private) {
+                metricTypes.push("nginx:status")
+            }
+        }
+
+        if (service.config.case === "workerConfig") {
+            serviceKind = "worker"
+            if (service.config.value.autoscaling != null && service.config.value.autoscaling.enabled) {
+                isHpaEnabled = true
+            }
+        }
+
+
+
+        if (isHpaEnabled) {
+            metricTypes.push("hpa_replicas");
+        }
+
+        return [serviceName, serviceKind, metricTypes, isHpaEnabled]
+    }, [selectedServiceName])
+
+
+  const { data: metricsData, isLoading: isMetricsDataLoading, refetch } = useQuery(
+    [
+        "getMetrics",
+        projectId,
+        clusterId,
+        serviceName,
+        selectedRange,
+        deploymentTargetId,
+    ],
+    async () => {
+
+      if (serviceName === "" || serviceKind === "" || metricTypes.length === 0) {
+        return;
+      }
+
+      const metrics: Metric[] = [];
+
+      const d = new Date();
+      const end = Math.round(d.getTime() / 1000);
+      const start = end - secondsBeforeNow[selectedRange];
+
+      for (const metricType of metricTypes) {
+          var kind = "";
+          if (serviceKind === "web") {
+              kind = "deployment";
+          } else if (serviceKind === "worker") {
+              kind = "deployment";
+          } else if (serviceKind === "job") {
+              kind = "job";
+          }
+          if (metricType === "nginx:status") {
+              kind = "Ingress"
+          }
+
+        const aggregatedMetricsResponse = await api.appMetrics(
+          "<token>",
+          {
+            metric: metricType,
+            shouldsum: false,
+            kind: kind,
+            name: serviceName,
+            deployment_target_id: deploymentTargetId,
+            startrange: start,
+            endrange: end,
+            resolution: resolutions[selectedRange],
+            pods: [],
+          },
+          {
+            id: projectId,
+            cluster_id: clusterId,
+          }
+        );
+
+        const metricsNormalizer = new MetricNormalizer(
+          [{ results: (aggregatedMetricsResponse.data ?? []).flatMap((d: any) => d.results) }],
+          metricType,
+        );
+        if (metricType === "nginx:status") {
+          const nginxMetric: NginxStatusMetric = {
+            type: metricType,
+            label: "Throughput",
+            areaData: metricsNormalizer.getNginxStatusData(),
+          }
+          metrics.push(nginxMetric)
+        } else {
+          const [data, allPodsAggregatedData] = metricsNormalizer.getAggregatedData();
+          const hpaData: NormalizedMetricsData[] = [];
+
+          if (isHpaEnabled && ["cpu", "memory"].includes(metricType)) {
+            let hpaMetricType = "cpu_hpa_threshold"
+            if (metricType === "memory") {
+              hpaMetricType = "memory_hpa_threshold"
+            }
+
+            const hpaRes = await api.appMetrics(
+              "<token>",
+              {
+                metric: hpaMetricType,
+                shouldsum: false,
+                kind: kind,
+                name: serviceName,
+                deployment_target_id: deploymentTargetId,
+                startrange: start,
+                endrange: end,
+                resolution: resolutions[selectedRange],
+                pods: [],
+              },
+              {
+                id: projectId,
+                cluster_id: clusterId,
+              }
+            );
+
+            const autoscalingMetrics = new MetricNormalizer(hpaRes.data, hpaMetricType as AvailableMetrics);
+            hpaData.push(...autoscalingMetrics.getParsedData());
+          }
+
+          const metric: Metric = match(metricType)
+            .with("cpu", () => ({
+              type: metricType,
+              label: "CPU Utilization (vCPUs)",
+              data: data,
+              aggregatedData: allPodsAggregatedData,
+              hpaData,
+            }))
+            .with("memory", () => ({
+              type: metricType,
+              label: "RAM Utilization (Mi)",
+              data: data,
+              aggregatedData: allPodsAggregatedData,
+              hpaData,
+            }))
+            .with("network", () => ({
+              type: metricType,
+              label: "Network Received Bytes (Ki)",
+              data: data,
+              aggregatedData: allPodsAggregatedData,
+              hpaData,
+            }))
+            .with("hpa_replicas", () => ({
+              type: metricType,
+              label: "Number of replicas",
+              data: data,
+              aggregatedData: allPodsAggregatedData,
+              hpaData,
+            }))
+            .with("nginx:errors", () => ({
+              type: metricType,
+              label: "5XX Error Percentage",
+              data: data,
+              aggregatedData: allPodsAggregatedData,
+              hpaData,
+            }))
+            .exhaustive();
+          metrics.push(metric);
+        }
+      };
+      return metrics;
+    },
+    {
+      enabled: selectedServiceName !== "",
+      refetchOnWindowFocus: false,
+      refetchInterval: 10000, // refresh metrics every 10 seconds
+    }
+  );
+
+  const renderMetrics = () => {
+    if (metricsData == null || isMetricsDataLoading) {
+      return <Loading />;
+    }
+    return metricsData.map((metric: Metric, i: number) => {
+      return (
+        <MetricsChart
+          key={metric.type}
+          metric={metric}
+          selectedRange={selectedRange}
+          isLoading={isMetricsDataLoading}
+          showAutoscalingLine={showAutoscalingThresholds}
+        />
+      );
+    })
+  }
+
+  const renderShowAutoscalingThresholdsCheckbox = (serviceName: string, isHpaEnabled: boolean) => {
+  if (serviceName === "") {
+    return null;
+  }
+
+    if (!isHpaEnabled) {
+      return null;
+    }
+    return (
+      <CheckboxRow
+        toggle={() => setShowAutoscalingThresholds(!showAutoscalingThresholds)}
+        checked={showAutoscalingThresholds}
+        label="Show Autoscaling Thresholds"
+      />
+    )
+  }
+
+  return (
+    <StyledMetricsSection>
+      <MetricsHeader>
+        <Flex>
+          <SelectRow
+            displayFlex={true}
+            label="Service"
+            value={selectedServiceName}
+            setActiveValue={(x: any) => setSelectedServiceName(x)}
+            options={serviceOptions}
+            width="200px"
+          />
+          <Highlight color={"#7d7d81"} onClick={() => refetch()}>
+            <i className="material-icons">autorenew</i>
+          </Highlight>
+          {renderShowAutoscalingThresholdsCheckbox(serviceName, isHpaEnabled)}
+        </Flex>
+        <RangeWrapper>
+          <Relative>
+          </Relative>
+          <TabSelector
+            noBuffer={true}
+            options={[
+              { value: "1H", label: "1H" },
+              { value: "6H", label: "6H" },
+              { value: "1D", label: "1D" },
+              { value: "1M", label: "1M" },
+            ]}
+            currentTab={selectedRange}
+            setCurrentTab={(x: string) => setSelectedRange(x)}
+          />
+        </RangeWrapper>
+      </MetricsHeader>
+      {renderMetrics()}
+    </StyledMetricsSection>
+  );
+};
+
+export default MetricsSection;
+
+const Relative = styled.div`
+  position: relative;
+`;
+
+const Flex = styled.div`
+  display: flex;
+  align-items: center;
+`;
+
+const MetricsHeader = styled.div`
+  width: 100%;
+  display: flex;
+  align-items: center;
+  overflow: visible;
+  justify-content: space-between;
+`;
+
+const RangeWrapper = styled.div`
+  float: right;
+  font-weight: bold;
+  width: 158px;
+  margin-top: -8px;
+`;
+
+const StyledMetricsSection = styled.div`
+  width: 100%;
+  display: flex;
+  flex-direction: column;
+  position: relative;
+`;
+
+const Highlight = styled.div`
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  margin-left: 8px;
+  margin-bottom: 15px;
+  margin-top: 20px;
+  color: ${(props: { color: string }) => props.color};
+  cursor: pointer;
+
+  > i {
+    font-size: 20px;
+    margin-right: 3px;
+  }
+`;

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

@@ -1476,6 +1476,27 @@ const getMetrics = baseApi<
   return `/api/projects/${pathParams.id}/clusters/${pathParams.cluster_id}/metrics`;
 });
 
+const appMetrics = baseApi<
+    {
+        metric: string;
+        shouldsum: boolean;
+        pods?: string[];
+        kind?: string; // the controller kind
+        name?: string;
+        percentile?: number;
+        deployment_target_id: string;
+        startrange: number;
+        endrange: number;
+        resolution: string;
+    },
+    {
+        id: number;
+        cluster_id: number;
+    }
+>("GET", (pathParams) => {
+    return `/api/projects/${pathParams.id}/clusters/${pathParams.cluster_id}/apps/metrics`;
+});
+
 const getNamespaces = baseApi<
   {},
   {
@@ -3007,6 +3028,7 @@ export default {
   getAllReleasePods,
   getClusterState,
   getMetrics,
+  appMetrics,
   getNamespaces,
   getNGINXIngresses,
   getOAuthIds,