瀏覽代碼

Refactor metrics, add loading states, add global refresh (#3424)

Feroze Mohideen 2 年之前
父節點
當前提交
90662ef046

+ 24 - 16
dashboard/src/components/Selector.tsx

@@ -1,6 +1,7 @@
 import React, { Component } from "react";
 import styled from "styled-components";
 import { Context } from "shared/Context";
+import Loading from "./Loading";
 
 export type SelectorPropsType<T> = {
   activeValue: T;
@@ -18,6 +19,7 @@ export type SelectorPropsType<T> = {
   placeholder?: string;
   scrollBuffer?: boolean;
   disableTooltip?: boolean;
+  isLoading?: boolean;
 };
 
 type StateType = {};
@@ -157,7 +159,7 @@ export default class Selector<T> extends Component<SelectorPropsType<T>, StateTy
   };
 
   render() {
-    let { activeValue } = this.props;
+    let { activeValue, isLoading } = this.props;
 
     return (
       <StyledSelector width={this.props.width}>
@@ -178,17 +180,23 @@ export default class Selector<T> extends Component<SelectorPropsType<T>, StateTy
           onMouseEnter={() => this.setState({ showTooltip: true })}
           onMouseLeave={() => this.setState({ showTooltip: false })}
         >
-          <Flex>
-            {this.renderIcon()}
-            <TextWrap>
-              {activeValue
-                ? activeValue === ""
-                  ? "All"
-                  : this.getLabel(activeValue)
-                : this.props.placeholder}
-            </TextWrap>
-          </Flex>
-          <i className="material-icons">arrow_drop_down</i>
+          {isLoading ?
+            <Loading />
+            :
+            <>
+              <Flex>
+                {this.renderIcon()}
+                <TextWrap>
+                  {activeValue
+                    ? activeValue === ""
+                      ? "All"
+                      : this.getLabel(activeValue)
+                    : this.props.placeholder}
+                </TextWrap>
+              </Flex>
+              <i className="material-icons">arrow_drop_down</i>
+            </>
+          }
         </MainSelector>
         {!this.props.disableTooltip && this.state.showTooltip && (
           <Tooltip>
@@ -328,7 +336,7 @@ const StyledSelector = styled.div<{ width: string }>`
   width: ${(props) => props.width};
 `;
 
-const MainSelector = styled.div<{ 
+const MainSelector = styled.div<{
   disabled?: boolean;
   expanded: boolean;
   width: string;
@@ -349,8 +357,8 @@ const MainSelector = styled.div<{
   background: ${props => props.expanded ? "#ffffff33" : props.theme.fg};
   :hover {
     background: ${props => props.expanded ? "#ffffff33" : (
-      props.disabled ? "#ffffff11" : "#ffffff22"
-    )};
+    props.disabled ? "#ffffff11" : "#ffffff22"
+  )};
   }
 
   > i {
@@ -389,4 +397,4 @@ const Tooltip = styled.div`
       opacity: 1;
     }
   }
-`;
+`;

+ 3 - 1
dashboard/src/components/form-components/SelectRow.tsx

@@ -16,6 +16,7 @@ type PropsType<T> = {
   doc?: string;
   disabled?: boolean;
   selectorProps?: Partial<SelectorPropsType<T>>;
+  isLoading?: boolean;
 };
 
 export default function SelectRow<T>(props: PropsType<T>) {
@@ -40,6 +41,7 @@ export default function SelectRow<T>(props: PropsType<T>) {
           width={props.width || "270px"}
           dropdownWidth={props.width}
           dropdownMaxHeight={props.dropdownMaxHeight}
+          isLoading={props.isLoading}
           {...(props.selectorProps || {})}
         />
       </SelectWrapper>
@@ -78,4 +80,4 @@ const StyledSelectRow = styled.div<{ displayFlex?: boolean }>`
   display: ${props => props.displayFlex ? "flex" : "block"};
   margin-bottom: 15px;
   margin-top: 20px;
-`;
+`;

+ 73 - 374
dashboard/src/main/home/app-dashboard/expanded-app/metrics/MetricsChart.tsx

@@ -1,394 +1,107 @@
-import React, { useContext, useEffect, useState } from "react";
+import React, { useState } from "react";
 import ParentSize from "@visx/responsive/lib/components/ParentSize";
-import axios from "axios"; 
 import styled from "styled-components";
 
-import api from "shared/api";
-import { ChartTypeWithExtendedConfig } from "shared/types";
-import { Context } from "shared/Context";
-
 import AggregatedDataLegend from "../../../cluster-dashboard/expanded-chart/metrics/AggregatedDataLegend";
 import StatusCodeDataLegend from "./StatusCodeDataLegend";
 import AreaChart from "./AreaChart";
 import StackedAreaChart from "./StackedAreaChart";
-import CheckboxRow from "components/form-components/CheckboxRow";
 import Loading from "components/Loading";
-import { AvailableMetrics, GenericMetricResponse, NormalizedMetricsData, NormalizedNginxStatusMetricsData } from "../../../cluster-dashboard/expanded-chart/metrics/types";
-import { MetricNormalizer } from "../../../cluster-dashboard/expanded-chart/metrics/MetricNormalizer";
-
-export const resolutions: { [range: string]: string } = {
-    "1H": "1s",
-    "6H": "15s",
-    "1D": "15s",
-    "1M": "5h",
-};
-
-export const secondsBeforeNow: { [range: string]: number } = {
-    "1H": 60 * 60,
-    "6H": 60 * 60 * 6,
-    "1D": 60 * 60 * 24,
-    "1M": 60 * 60 * 24 * 30,
-};
+import { AggregatedMetric, Metric, NginxStatusMetric, isNginxMetric } from "./types";
 
 type PropsType = {
-    currentChart: ChartTypeWithExtendedConfig;
-    pods: any[];
-    selectedController: any;
-    selectedIngress: any;
-    selectedMetric: string;
-    selectedMetricLabel: string;
-    selectedPod: string;
+    metric: Metric;
     selectedRange: string;
+    isLoading: boolean;
+    showAutoscalingLine: boolean;
 };
 
 const MetricsChart: React.FunctionComponent<PropsType> = ({
-    currentChart,
-    pods,
-    selectedController,
-    selectedIngress,
-    selectedMetric,
-    selectedMetricLabel,
-    selectedPod,
+    metric,
     selectedRange,
+    isLoading,
+    showAutoscalingLine,
 }) => {
-    const [isAggregated, setIsAggregated] = useState<boolean>(false);
-    const [aggregatedData, setAggregatedData] = useState<
-        Record<string, NormalizedMetricsData[]>
-    >({});
-    const [data, setData] = useState<NormalizedMetricsData[]>([]);
-    const [areaData, setAreaData] = useState<NormalizedNginxStatusMetricsData[]>([]);
-    const [hpaData, setHpaData] = useState<NormalizedMetricsData[]>([]);
-    const [hpaEnabled, setHpaEnabled] = useState(false);
-    const [showHpaToggle, setShowHpaToggle] = useState(false);
-    const [isLoading, setIsLoading] = useState(0);
-
-    const { currentCluster, currentProject, setCurrentError } = useContext(
-        Context
-    );
-
-    const getMetrics = async () => {
-        if (selectedMetric == "nginx:status") {
-            getNginxMetrics();
-        } else {
-            getAppMetrics();
+    // TODO: fix the type-filtering here
+    const renderMetric = (metric: Metric) => {
+        if (isNginxMetric(metric)) {
+            return renderNginxMetric(metric);
         }
-    };
-
-    const getAppMetrics = async () => {
-        if (pods?.length == 0) {
-            return;
-        }
-
-        if (selectedMetric == "nginx:status") {
-            return;
+        return renderAggregatedMetric(metric as AggregatedMetric);
+    }
+    const renderAggregatedMetric = (metric: AggregatedMetric) => {
+        if (metric.data.length === 0) {
+            return (
+                <Message>
+                    No data available yet.
+                </Message>
+            )
         }
+        return (
+            <>
+                <ParentSize>
+                    {({ width, height }) => (
+                        <AreaChart
+                            dataKey={metric.label}
+                            aggregatedData={metric.aggregatedData}
+                            isAggregated={true}
+                            data={metric.data}
+                            hpaData={metric.hpaData}
+                            hpaEnabled={showAutoscalingLine && metric.hpaData.length > 0}
+                            width={width}
+                            height={height - 10}
+                            resolution={selectedRange}
+                            margin={{ top: 40, right: -40, bottom: 0, left: 50 }}
+                        />
+                    )}
+                </ParentSize>
+                <RowWrapper>
+                    <AggregatedDataLegend data={metric.data} hideAvg={true} />
+                </RowWrapper>
+            </>
+        )
+    }
 
-        try {
-            let shouldsum = selectedPod === "All";
-            let namespace = currentChart.namespace;
-
-            // calculate start and end range
-            const d = new Date();
-            const end = Math.round(d.getTime() / 1000);
-            const start = end - secondsBeforeNow[selectedRange];
-
-            let podNames = [] as string[];
-
-            if (!shouldsum) {
-                podNames = [selectedPod];
-            }
-
-            if (selectedMetric == "nginx:errors") {
-                podNames = [selectedIngress?.name];
-                namespace = selectedIngress?.namespace || "default";
-                shouldsum = false;
-            }
-
-            let kind = selectedController?.kind
-            if (selectedMetric == "nginx:status") {
-                kind = "Ingress";
-            }
-
-            const serviceName: string = selectedController?.metadata.labels["app.kubernetes.io/name"]
-            const isHpaEnabled: boolean = currentChart?.config?.[serviceName]?.autoscaling?.enabled
-
-            setIsLoading((prev) => prev + 1);
-            setAggregatedData({});
-            setIsAggregated(shouldsum);
-            setShowHpaToggle(isHpaEnabled);
-            setHpaEnabled(isHpaEnabled);
-
-            const aggregatedMetricsRequest = api.getMetrics(
-                "<token>",
-                {
-                    metric: selectedMetric,
-                    shouldsum: false,
-                    kind: kind,
-                    name: selectedController?.metadata.name,
-                    namespace: namespace,
-                    startrange: start,
-                    endrange: end,
-                    resolution: resolutions[selectedRange],
-                    pods: [],
-                },
-                {
-                    id: currentProject.id,
-                    cluster_id: currentCluster.id,
-                }
+    const renderNginxMetric = (metric: NginxStatusMetric) => {
+        if (metric.areaData.length === 0) {
+            return (
+                <Message>
+                    No data available yet.
+                </Message>
             );
-
-            let requests = [
-                aggregatedMetricsRequest,
-            ];
-
-            if (!shouldsum) {
-                const metricsRequest = api.getMetrics(
-                    "<token>",
-                    {
-                        metric: selectedMetric,
-                        shouldsum: shouldsum,
-                        kind: kind,
-                        name: selectedController?.metadata.name,
-                        namespace: namespace,
-                        startrange: start,
-                        endrange: end,
-                        resolution: resolutions[selectedRange],
-                        pods: podNames,
-                    },
-                    {
-                        id: currentProject.id,
-                        cluster_id: currentCluster.id,
-                    }
-                );
-                requests.push(metricsRequest);
-            }
-
-            if (shouldsum && isHpaEnabled && ["cpu", "memory"].includes(selectedMetric)) {
-                let hpaMetricType = "cpu_hpa_threshold";
-                if (selectedMetric === "memory") {
-                    hpaMetricType = "memory_hpa_threshold";
-                }
-                const hpaMetricsRequest = api.getMetrics(
-                    "<token>",
-                    {
-                        metric: hpaMetricType,
-                        shouldsum: shouldsum,
-                        kind: kind,
-                        name: selectedController?.metadata.name,
-                        namespace: namespace,
-                        startrange: start,
-                        endrange: end,
-                        resolution: resolutions[selectedRange],
-                        pods: [],
-                    },
-                    {
-                        id: currentProject.id,
-                        cluster_id: currentCluster.id,
-                    }
-                );
-                requests.push(hpaMetricsRequest);
-            }
-
-            axios
-                .all(requests)
-                .then((responses) => {
-                    const allPodsRes = responses[0];
-                    const allPodsData: GenericMetricResponse[] = allPodsRes.data ?? [];
-                    const allPodsMetrics = allPodsData.flatMap((d) => d.results);
-                    const allPodsMetricsNormalized = new MetricNormalizer(
-                        [{ results: allPodsMetrics }],
-                        selectedMetric as AvailableMetrics,
-                    );
-                    const allPodsAggregatedData = allPodsMetricsNormalized.getAggregatedData()
-                    if (shouldsum) {
-                        setData(allPodsAggregatedData["avg"])
-                        delete allPodsAggregatedData["avg"]
-                    }
-
-                    setAggregatedData(allPodsAggregatedData);
-
-                    if (!shouldsum) {
-                        const res = responses[1];
-                        const metrics = new MetricNormalizer(
-                            res.data,
-                            selectedMetric as AvailableMetrics
-                        );
-                        setData(metrics.getParsedData());
-                    }
-
-                    if (shouldsum && isHpaEnabled && ["cpu", "memory"].includes(selectedMetric)) {
-                        let hpaMetricType = "cpu_hpa_threshold"
-                        if (selectedMetric === "memory") {
-                            hpaMetricType = "memory_hpa_threshold"
-                        }
-                        const hpaRes = responses[1];
-                        if (!Array.isArray(hpaRes.data) || !hpaRes.data[0]?.results) {
-                            return;
-                        }
-                        const autoscalingMetrics = new MetricNormalizer(hpaRes.data, hpaMetricType as AvailableMetrics);
-                        setHpaData(autoscalingMetrics.getParsedData());
-                    }
-                })
-                .catch(error => {
-                    setCurrentError(JSON.stringify(error));
-                })
-        } catch (error) {
-            setCurrentError(JSON.stringify(error));
-        } finally {
-            setIsLoading((prev) => prev - 1);
-        }
-    };
-
-    const getNginxMetrics = async () => {
-        const name = selectedController?.metadata.name
-        if (name.length === undefined) {
-            return
         }
 
-        if (selectedMetric != "nginx:status") {
-            return;
-        }
-
-        let requests = [];
-        const namespace = currentChart.namespace;
-        const kind = "Ingress";
-
-        // calculate start and end range
-        const d = new Date();
-        const end = Math.round(d.getTime() / 1000);
-        const start = end - secondsBeforeNow[selectedRange];
-
-        try {
-            setIsLoading((prev) => prev + 1);
-            const response = await api.getMetrics(
-                "<token>",
-                {
-                    metric: selectedMetric,
-                    shouldsum: false,
-                    kind: kind,
-                    name: selectedController?.metadata.name,
-                    namespace: namespace,
-                    startrange: start,
-                    endrange: end,
-                    resolution: resolutions[selectedRange],
-                    pods: [],
-                },
-                {
-                    id: currentProject.id,
-                    cluster_id: currentCluster.id,
-                }
-            );
-            let aggregatedMetrics: Record<string, NormalizedMetricsData[]> = {};
-            const metrics = new MetricNormalizer(
-                response.data,
-                selectedMetric as AvailableMetrics
-            );
-
-            setAreaData(metrics.getNginxStatusData());
-        } catch (error) {
-            setCurrentError(JSON.stringify(error));
-        } finally {
-            setIsLoading((prev) => prev - 1);
-        }
+        return (
+            <>
+                <ParentSize>
+                    {({ width, height }) => (
+                        <StackedAreaChart
+                            dataKey={metric.label}
+                            data={metric.areaData}
+                            width={width}
+                            height={height - 10}
+                            resolution={selectedRange}
+                            margin={{ top: 40, right: -40, bottom: 0, left: 50 }}
+                        />
+                    )}
+                </ParentSize>
+                <RowWrapper>
+                    <StatusCodeDataLegend />
+                </RowWrapper>
+            </>
+        )
     }
 
-    useEffect(() => {
-        if (selectedRange && selectedController) {
-            getNginxMetrics();
-        }
-    }, [
-        selectedRange,
-        selectedController,
-    ]);
-
-    useEffect(() => {
-        if (selectedRange && selectedPod && selectedController) {
-            getAppMetrics();
-        }
-    }, [
-        selectedRange,
-        selectedPod,
-        selectedController,
-        selectedIngress,
-        pods,
-    ]);
-
     return (
         <StyledMetricsChart>
             <MetricsHeader>
                 <Flex>
                     <MetricSelector>
-                        <MetricsLabel>{selectedMetricLabel}</MetricsLabel>
+                        <MetricsLabel>{metric.label}</MetricsLabel>
                     </MetricSelector>
-
-                    <Highlight color={"#7d7d81"} onClick={getMetrics}>
-                        <i className="material-icons">autorenew</i>
-                    </Highlight>
                 </Flex>
             </MetricsHeader>
-            {isLoading > 0 && <Loading />}
-            {(data.length === 0 && Object.keys(areaData).length === 0) && isLoading === 0 && (
-                <Message>
-                    No data available yet.
-                    <Highlight color={"#8590ff"} onClick={getMetrics}>
-                        <i className="material-icons">autorenew</i>
-                        Refresh
-                    </Highlight>
-                </Message>
-            )}
-            {data.length > 0 && isLoading === 0 && (
-                <>
-                    {showHpaToggle &&
-                        ["cpu", "memory"].includes(selectedMetric) && (
-                            <CheckboxRow
-                                toggle={() => setHpaEnabled((prev: any) => !prev)}
-                                checked={hpaEnabled}
-                                label="Show Autoscaling Threshold"
-                            />
-                        )}
-                    <ParentSize>
-                        {({ width, height }) => (
-                            <AreaChart
-                                dataKey={selectedMetricLabel}
-                                aggregatedData={aggregatedData}
-                                isAggregated={isAggregated}
-                                data={data}
-                                hpaData={hpaData}
-                                hpaEnabled={
-                                    hpaEnabled && ["cpu", "memory"].includes(selectedMetric)
-                                }
-                                width={width}
-                                height={height - 10}
-                                resolution={selectedRange}
-                                margin={{ top: 40, right: -40, bottom: 0, left: 50 }}
-                            />
-                        )}
-                    </ParentSize>
-                    <RowWrapper>
-                        <AggregatedDataLegend data={data} hideAvg={isAggregated} />
-                    </RowWrapper>
-                </>
-            )}
-
-            {Object.keys(areaData).length > 0 && isLoading === 0 && (
-                <>
-                    <ParentSize>
-                        {({ width, height }) => (
-                            <StackedAreaChart
-                                dataKey={selectedMetricLabel}
-                                data={areaData}
-                                width={width}
-                                height={height - 10}
-                                resolution={selectedRange}
-                                margin={{ top: 40, right: -40, bottom: 0, left: 50 }}
-                            />
-                        )}
-                    </ParentSize>
-                    <RowWrapper>
-                        <StatusCodeDataLegend />
-                    </RowWrapper>
-                </>
-            )}
+            {isLoading ? <Loading /> : renderMetric(metric)}
         </StyledMetricsChart>
     );
 };
@@ -400,20 +113,6 @@ const Flex = styled.div`
   align-items: center;
 `;
 
-const Highlight = styled.div`
-  display: flex;
-  align-items: center;
-  justify-content: center;
-  margin-left: 8px;
-  color: ${(props: { color: string }) => props.color};
-  cursor: pointer;
-
-  > i {
-    font-size: 20px;
-    margin-right: 3px;
-  }
-`;
-
 const Message = styled.div`
   display: flex;
   height: 100%;
@@ -491,4 +190,4 @@ const StyledMetricsChart = styled.div`
       transform: translateY(0px);
     }
   }
-`;
+`;

+ 248 - 215
dashboard/src/main/home/app-dashboard/expanded-app/metrics/MetricsSection.tsx

@@ -3,84 +3,52 @@ import styled from "styled-components";
 
 import api from "shared/api";
 import { Context } from "shared/Context";
-import { ChartTypeWithExtendedConfig } from "shared/types";
+import { ChartType } from "shared/types";
 
 import TabSelector from "components/TabSelector";
 import SelectRow from "components/form-components/SelectRow";
+import { getServiceNameFromControllerName, MetricNormalizer, resolutions, secondsBeforeNow } from "./utils";
+import { Metric, MetricType, NginxStatusMetric } from "./types";
+import { match } from "ts-pattern";
+import { AvailableMetrics, NormalizedMetricsData } from "main/home/cluster-dashboard/expanded-chart/metrics/types";
 import MetricsChart from "./MetricsChart";
-import { getServiceNameFromControllerName } from "./utils";
+import { useQuery } from "@tanstack/react-query";
+import Loading from "components/Loading";
+import CheckboxRow from "components/CheckboxRow";
+
 type PropsType = {
-  currentChart: ChartTypeWithExtendedConfig;
+  currentChart: ChartType;
   appName: string;
   serviceName?: string;
 };
 
-export const resolutions: { [range: string]: string } = {
-  "1H": "1s",
-  "6H": "15s",
-  "1D": "15s",
-  "1M": "5h",
-};
-
-export const secondsBeforeNow: { [range: string]: number } = {
-  "1H": 60 * 60,
-  "6H": 60 * 60 * 6,
-  "1D": 60 * 60 * 24,
-  "1M": 60 * 60 * 24 * 30,
-};
-
 const MetricsSection: React.FunctionComponent<PropsType> = ({
   currentChart,
   appName,
   serviceName,
 }) => {
-  const [pods, setPods] = useState([]);
-  const [controllerOptions, setControllerOptions] = useState([]);
-  const [selectedController, setSelectedController] = useState<any>();
-  const [ingressOptions, setIngressOptions] = useState([]);
-  const [selectedIngress, setSelectedIngress] = useState(null);
+  const [selectedController, setSelectedController] = useState<any>(null);
   const [selectedRange, setSelectedRange] = useState("1H");
-  const [selectedMetric, setSelectedMetric] = useState("cpu");
-  const [isLoading, setIsLoading] = useState(0);
+  const [showAutoscalingThresholds, setShowAutoscalingThresholds] = useState(false);
 
-  const { currentCluster, currentProject, setCurrentError } = useContext(
+  const { currentCluster, currentProject } = useContext(
     Context
   );
 
-  useEffect(() => {
-    if (currentChart?.chart?.metadata?.name === "ingress-nginx") {
-      setIsLoading((prev) => prev + 1);
-
-      api
-        .getNGINXIngresses(
-          "<token>",
-          {},
-          {
-            id: currentProject.id,
-            cluster_id: currentCluster.id,
-          }
-        )
-        .then((res) => {
-          const ingressOptions = res.data.map((ingress: any) => ({
-            value: ingress,
-            label: ingress.name,
-          }));
-          setIngressOptions(ingressOptions);
-          setSelectedIngress(ingressOptions[0]?.value);
-          // iterate through the controllers to get the list of pods
-        })
-        .catch((err) => {
-          setCurrentError(JSON.stringify(err));
-        })
-        .finally(() => {
-          setIsLoading((prev) => prev - 1);
-        });
-    }
-
-    setIsLoading((prev) => prev + 1);
-
-    api
-      .getChartControllers(
+  const { data: controllerOptions, isLoading: isControllerListLoading } = useQuery(
+    [
+      "getChartControllers",
+      currentProject?.id,
+      currentChart.name,
+      currentChart.namespace,
+      currentCluster?.id,
+      currentChart.version,
+    ],
+    async () => {
+      if (currentProject?.id == null || currentCluster?.id == null) {
+        return;
+      }
+      const res = await api.getChartControllers(
         "<token>",
         {},
         {
@@ -90,126 +58,227 @@ const MetricsSection: React.FunctionComponent<PropsType> = ({
           cluster_id: currentCluster.id,
           revision: currentChart.version,
         }
-      )
-      .then((res) => {
-        const controllerOptions = res.data.map((controller: any) => {
-          return { value: controller, label: getServiceNameFromControllerName(controller?.metadata?.name, appName) };
-        });
-
-        setControllerOptions(controllerOptions);
-        const controllerOption = controllerOptions.find(
-          (option: any) => option.label === serviceName
-        );
-        if (controllerOption) {
-          setSelectedController(controllerOption.value);
-        } else {
-          setSelectedController(controllerOptions[0]?.value);
-        }
-      })
-      .catch((err) => {
-        setCurrentError(JSON.stringify(err));
-        setControllerOptions([]);
-      })
-      .finally(() => {
-        setIsLoading((prev) => prev - 1);
+      );
+
+      const controllerOptions = res.data.map((controller: any) => {
+        return { value: controller, label: getServiceNameFromControllerName(controller?.metadata?.name, appName) };
       });
-  }, [currentChart, currentCluster, currentProject]);
 
-  useEffect(() => {
-    getPods();
-  }, [selectedController]);
-
-  const getPods = () => {
-    let selectors = [] as string[];
-    let ml =
-      selectedController?.spec?.selector?.matchLabels ||
-      selectedController?.spec?.selector;
-    let i = 1;
-    let selector = "";
-    for (var key in ml) {
-      selector += key + "=" + ml[key];
-      if (i != Object.keys(ml).length) {
-        selector += ",";
-      }
-      i += 1;
+      return controllerOptions;
     }
+  );
 
-    selectors.push(selector);
+  const { data: metricsData, isLoading: isMetricsDataLoading, refetch } = useQuery(
+    [
+      "getMetrics",
+      currentProject?.id,
+      currentCluster?.id,
+      selectedController?.metadata?.name,
+      selectedRange,
+    ],
+    async () => {
+      if (currentProject?.id == null || currentCluster?.id == null) {
+        return;
+      }
+      const metrics: Metric[] = [];
+      const metricTypes: MetricType[] = ["cpu", "memory", "network"];
 
-    if (selectors[0] === "") {
-      return;
-    }
+      const serviceName: string = selectedController?.metadata.labels["app.kubernetes.io/name"]
+      const isHpaEnabled: boolean = currentChart?.config?.[serviceName]?.autoscaling?.enabled
 
-    setIsLoading((prev) => prev + 1);
+      if (isHpaEnabled) {
+        metricTypes.push("hpa_replicas");
+      }
 
-    api
-      .getMatchingPods(
-        "<token>",
-        {
-          namespace: selectedController?.metadata?.namespace,
-          selectors,
-        },
-        {
-          id: currentProject.id,
-          cluster_id: currentCluster.id,
+      if (currentChart?.chart?.metadata?.name == "ingress-nginx") {
+        metricTypes.push("nginx:errors");
+      }
+
+      if (currentChart?.config?.[serviceName]?.ingress?.enabled) {
+        metricTypes.push("nginx:status")
+      }
+
+      const d = new Date();
+      const end = Math.round(d.getTime() / 1000);
+      const start = end - secondsBeforeNow[selectedRange];
+
+      for (const metricType of metricTypes) {
+        const kind = metricType === "nginx:status" ? "Ingress" : selectedController?.kind
+
+        const aggregatedMetricsResponse = await api.getMetrics(
+          "<token>",
+          {
+            metric: metricType,
+            shouldsum: false,
+            kind: kind,
+            name: selectedController?.metadata.name,
+            namespace: currentChart.namespace,
+            startrange: start,
+            endrange: end,
+            resolution: resolutions[selectedRange],
+            pods: [],
+          },
+          {
+            id: currentProject.id,
+            cluster_id: currentCluster.id,
+          }
+        );
+        // TODO: type the response to this
+        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.getMetrics(
+              "<token>",
+              {
+                metric: hpaMetricType,
+                shouldsum: true,
+                kind: kind,
+                name: selectedController?.metadata.name,
+                namespace: currentChart.namespace,
+                startrange: start,
+                endrange: end,
+                resolution: resolutions[selectedRange],
+                pods: [],
+              },
+              {
+                id: currentProject.id,
+                cluster_id: currentCluster.id,
+              }
+            );
+
+            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);
         }
-      )
-      .then((res) => {
-        let pods = [{ value: "All", label: "All (Summed)" }] as any[];
-        res?.data?.forEach((pod: any) => {
-          let name = pod?.metadata?.name;
-          pods.push({ value: name, label: name });
-        });
-        setPods(pods);
-      })
-      .catch((err) => {
-        setCurrentError(JSON.stringify(err));
-        return;
-      })
-      .finally(() => {
-        setIsLoading((prev) => prev - 1);
-      });
-  };
+      };
+      return metrics;
+    },
+    {
+      enabled: selectedController != null,
+    }
+  );
+
+  useEffect(() => {
+    if (controllerOptions == null) {
+      return;
+    }
+    const controllerOption = controllerOptions.find(
+      (option: any) => option.label === serviceName
+    );
+    if (controllerOption) {
+      setSelectedController(controllerOption.value);
+    } else {
+      setSelectedController(controllerOptions[0]?.value);
+    }
+  }, [controllerOptions]);
+
+  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 renderHpaChart = () => {
+  const renderShowAutoscalingThresholdsCheckbox = () => {
     const serviceName: string = selectedController?.metadata.labels["app.kubernetes.io/name"]
     const isHpaEnabled: boolean = currentChart?.config?.[serviceName]?.autoscaling?.enabled
-    return isHpaEnabled ? (
-      <MetricsChart
-        currentChart={currentChart}
-        selectedController={selectedController}
-        selectedIngress={selectedIngress}
-        selectedMetric="hpa_replicas"
-        selectedMetricLabel="Number of replicas"
-        selectedPod="All"
-        selectedRange={selectedRange}
-        pods={pods}
+
+    if (!isHpaEnabled) {
+      return null;
+    }
+    return (
+      <CheckboxRow
+        toggle={() => setShowAutoscalingThresholds(!showAutoscalingThresholds)}
+        checked={showAutoscalingThresholds}
+        label="Show Autoscaling Thresholds"
       />
-    ) : null
-  };
+    )
+  }
 
   return (
     <StyledMetricsSection>
       <MetricsHeader>
         <Flex>
-          {selectedMetric === "nginx:errors" ?
-            <SelectRow
-              displayFlex={true}
-              label="Target Ingress"
-              value={selectedIngress}
-              setActiveValue={(x: any) => setSelectedIngress(x)}
-              options={ingressOptions}
-              width="100%"
-            /> :
-            <SelectRow
-              displayFlex={true}
-              label="Service"
-              value={selectedController}
-              setActiveValue={(x: any) => setSelectedController(x)}
-              options={controllerOptions}
-              width="100%"
-            />
-          }
+          <SelectRow
+            displayFlex={true}
+            label="Service"
+            value={selectedController}
+            setActiveValue={(x: any) => setSelectedController(x)}
+            options={controllerOptions}
+            width="200px"
+            isLoading={isControllerListLoading}
+          />
+          <Highlight color={"#7d7d81"} onClick={() => refetch()}>
+            <i className="material-icons">autorenew</i>
+          </Highlight>
+          {renderShowAutoscalingThresholdsCheckbox()}
         </Flex>
         <RangeWrapper>
           <Relative>
@@ -227,59 +296,7 @@ const MetricsSection: React.FunctionComponent<PropsType> = ({
           />
         </RangeWrapper>
       </MetricsHeader>
-      <MetricsChart
-        currentChart={currentChart}
-        selectedController={selectedController}
-        selectedIngress={selectedIngress}
-        selectedMetric="cpu"
-        selectedMetricLabel="CPU Utilization (vCPUs)"
-        selectedPod="All"
-        selectedRange={selectedRange}
-        pods={pods}
-      />
-      <MetricsChart
-        currentChart={currentChart}
-        selectedController={selectedController}
-        selectedIngress={selectedIngress}
-        selectedMetric="memory"
-        selectedMetricLabel="RAM Utilization (Mi)"
-        selectedPod="All"
-        selectedRange={selectedRange}
-        pods={pods}
-      />
-      <MetricsChart
-        currentChart={currentChart}
-        selectedController={selectedController}
-        selectedIngress={selectedIngress}
-        selectedMetric="network"
-        selectedMetricLabel="Network Received Bytes (Ki)"
-        selectedPod="All"
-        selectedRange={selectedRange}
-        pods={pods}
-      />
-      <MetricsChart
-        currentChart={currentChart}
-        selectedController={selectedController}
-        selectedIngress={selectedIngress}
-        selectedMetric="nginx:status"
-        selectedMetricLabel="Throughput"
-        selectedPod="All"
-        selectedRange={selectedRange}
-        pods={pods}
-      />
-      {renderHpaChart()}
-      {currentChart?.chart?.metadata?.name == "ingress-nginx" && (
-        <MetricsChart
-          currentChart={currentChart}
-          selectedController={selectedController}
-          selectedIngress={selectedIngress}
-          selectedMetric="nginx:errors"
-          selectedMetricLabel="5XX Error Percentage"
-          selectedPod="All"
-          selectedRange={selectedRange}
-          pods={pods}
-        />
-      )}
+      {renderMetrics()}
     </StyledMetricsSection>
   );
 };
@@ -316,3 +333,19 @@ const StyledMetricsSection = styled.div`
   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;
+  }
+`;

+ 20 - 0
dashboard/src/main/home/app-dashboard/expanded-app/metrics/types.ts

@@ -0,0 +1,20 @@
+import { NormalizedMetricsData, NormalizedNginxStatusMetricsData } from "main/home/cluster-dashboard/expanded-chart/metrics/types";
+
+export type MetricType = 'cpu' | 'memory' | 'network' | 'nginx:status' | 'nginx:errors' | 'hpa_replicas';
+export interface Metric {
+    type: MetricType;
+    label: string;
+}
+export interface AggregatedMetric extends Metric {
+    data: NormalizedMetricsData[];
+    aggregatedData: Record<string, NormalizedMetricsData[]>;
+    hpaData: NormalizedMetricsData[];
+}
+export interface NginxStatusMetric extends Metric {
+    areaData: NormalizedNginxStatusMetricsData[];
+}
+
+export const isNginxMetric = (metric: Metric): metric is NginxStatusMetric => {
+    return metric.type === 'nginx:status';
+}
+

+ 190 - 0
dashboard/src/main/home/app-dashboard/expanded-app/metrics/utils.ts

@@ -1,3 +1,20 @@
+import * as stats from "simple-statistics";
+import _ from "lodash";
+
+import {
+    GenericMetricResponse,
+    MetricsCPUDataResponse,
+    MetricsMemoryDataResponse,
+    MetricsNetworkDataResponse,
+    MetricsNGINXErrorsDataResponse,
+    MetricsNGINXStatusDataResponse,
+    AvailableMetrics,
+    MetricsHpaReplicasDataResponse,
+    MetricsNGINXLatencyDataResponse,
+    NormalizedMetricsData,
+    NormalizedNginxStatusMetricsData,
+} from "main/home/cluster-dashboard/expanded-chart/metrics/types";
+
 // these match log colors
 export const StatusCodeDataColors: Record<string, string> = {
     "1xx": "#4B4F7C", // gray
@@ -92,3 +109,176 @@ export const getServiceNameFromControllerName = (controllerName: string, porterA
     return index !== -1 ? controllerName.substring(0, index) : controllerName;
 }
 
+/**
+ * Normalize values from the API to be readable by the AreaChart component.
+ * This class was created to reduce the amount of parsing inside the MetricsSection component
+ * and improve readability
+ */
+export class MetricNormalizer {
+    metric_results: GenericMetricResponse["results"];
+    kind: AvailableMetrics;
+
+    constructor(data: GenericMetricResponse[], kind: AvailableMetrics) {
+        if (!Array.isArray(data) || !data[0]?.results) {
+            throw new Error("Failed parsing response" + JSON.stringify(data));
+        }
+        this.metric_results = data[0].results;
+        this.kind = kind;
+    }
+
+    getParsedData(): NormalizedMetricsData[] {
+        if (this.kind.includes("cpu")) {
+            return this.parseCPUMetrics(this.metric_results);
+        }
+        if (this.kind.includes("memory")) {
+            return this.parseMemoryMetrics(this.metric_results);
+        }
+        if (this.kind.includes("network")) {
+            return this.parseNetworkMetrics(this.metric_results);
+        }
+        if (this.kind.includes("nginx:errors")) {
+            return this.parseNGINXErrorsMetrics(this.metric_results);
+        }
+        if (
+            this.kind.includes("nginx:latency") ||
+            this.kind.includes("nginx:latency-histogram")
+        ) {
+            return this.parseNGINXLatencyMetrics(this.metric_results);
+        }
+        if (this.kind.includes("hpa_replicas")) {
+            return this.parseHpaReplicaMetrics(this.metric_results);
+        }
+        return [];
+    }
+
+    getNginxStatusData(): NormalizedNginxStatusMetricsData[] {
+        if (this.kind.includes("nginx:status")) {
+            return this.parseNGINXStatusMetrics(this.metric_results);
+        }
+
+        return []
+    }
+
+    getAggregatedData(): [NormalizedMetricsData[], Record<string, NormalizedMetricsData[]>] {
+        const groupedByDate = _.groupBy(this.getParsedData(), "date");
+
+        const avg = Object.keys(groupedByDate).map((date) => {
+            const values = groupedByDate[date].map((d) => d.value);
+            return {
+                date: Number(date),
+                value: stats.mean(values),
+            };
+        });
+
+        const min = Object.keys(groupedByDate).map((date) => {
+            const values = groupedByDate[date].map((d) => d.value);
+            return {
+                date: Number(date),
+                value: stats.min(values),
+            };
+        });
+
+        const max = Object.keys(groupedByDate).map((date) => {
+            const values = groupedByDate[date].map((d) => d.value);
+            return {
+                date: Number(date),
+                value: stats.max(values),
+            };
+        });
+
+        return [avg, {
+            min,
+            max,
+        }];
+    }
+
+    private parseCPUMetrics(arr: MetricsCPUDataResponse["results"]) {
+        return arr.map((d) => {
+            return {
+                date: d.date,
+                value: parseFloat(d.cpu),
+            };
+        });
+    }
+
+    private parseMemoryMetrics(arr: MetricsMemoryDataResponse["results"]) {
+        return arr.map((d) => {
+            return {
+                date: d.date,
+                value: parseFloat(d.memory) / (1024 * 1024), // put units in Mi
+            };
+        });
+    }
+
+    private parseNetworkMetrics(arr: MetricsNetworkDataResponse["results"]) {
+        return arr.map((d) => {
+            return {
+                date: d.date,
+                value: parseFloat(d.bytes) / 1024, // put units in Ki
+            };
+        });
+    }
+
+    private parseNGINXErrorsMetrics(
+        arr: MetricsNGINXErrorsDataResponse["results"]
+    ) {
+        return arr.map((d) => {
+            return {
+                date: d.date,
+                value: parseFloat(d.error_pct),
+            };
+        });
+    }
+
+    private parseNGINXStatusMetrics(
+        arr: MetricsNGINXStatusDataResponse["results"]
+    ) {
+        return arr.map((d) => {
+            return {
+                date: d.date,
+                "1xx": parseInt(d["1xx"]),
+                "2xx": parseInt(d["2xx"]),
+                "3xx": parseInt(d["3xx"]),
+                "4xx": parseInt(d["4xx"]),
+                "5xx": parseInt(d["5xx"]),
+            };
+        });
+    }
+
+    private parseNGINXLatencyMetrics(
+        arr: MetricsNGINXLatencyDataResponse["results"]
+    ) {
+        return arr.map((d) => {
+            return {
+                date: d.date,
+                value: d.latency != "NaN" ? parseFloat(d.latency) : 0,
+            };
+        });
+    }
+
+    private parseHpaReplicaMetrics(
+        arr: MetricsHpaReplicasDataResponse["results"]
+    ) {
+        return arr.map((d) => {
+            return {
+                date: d.date,
+                value: parseInt(d.replicas),
+            };
+        });
+    }
+}
+
+export const resolutions: { [range: string]: string } = {
+    "1H": "1s",
+    "6H": "15s",
+    "1D": "15s",
+    "1M": "5h",
+};
+
+export const secondsBeforeNow: { [range: string]: number } = {
+    "1H": 60 * 60,
+    "6H": 60 * 60 * 6,
+    "1D": 60 * 60 * 24,
+    "1M": 60 * 60 * 24 * 30,
+};
+