David Townley 2 سال پیش
والد
کامیت
703934cf73
19فایلهای تغییر یافته به همراه1741 افزوده شده و 1010 حذف شده
  1. 5 5
      dashboard/src/components/Selector.tsx
  2. 12 12
      dashboard/src/components/TabSelector.tsx
  3. 19 23
      dashboard/src/components/form-components/SelectRow.tsx
  4. 1 1
      dashboard/src/main/home/app-dashboard/expanded-app/ExpandedApp.tsx
  5. 0 755
      dashboard/src/main/home/app-dashboard/expanded-app/MetricsSection.tsx
  6. 520 0
      dashboard/src/main/home/app-dashboard/expanded-app/metrics/AreaChart.tsx
  7. 53 0
      dashboard/src/main/home/app-dashboard/expanded-app/metrics/DataLegend.tsx
  8. 150 0
      dashboard/src/main/home/app-dashboard/expanded-app/metrics/MetricNormalizer.ts
  9. 519 0
      dashboard/src/main/home/app-dashboard/expanded-app/metrics/MetricsBase.tsx
  10. 71 0
      dashboard/src/main/home/app-dashboard/expanded-app/metrics/MetricsSection.tsx
  11. 192 0
      dashboard/src/main/home/app-dashboard/expanded-app/metrics/styles.tsx
  12. 121 0
      dashboard/src/main/home/app-dashboard/expanded-app/metrics/types.ts
  13. 57 0
      dashboard/src/main/home/app-dashboard/expanded-app/metrics/utils.ts
  14. 3 3
      dashboard/src/main/home/cluster-dashboard/dashboard/Metrics.tsx
  15. 1 2
      dashboard/src/main/home/cluster-dashboard/expanded-chart/logs-section/useAgentLogs.ts
  16. 3 195
      dashboard/src/main/home/cluster-dashboard/expanded-chart/metrics/JobMetricsSection.tsx
  17. 10 10
      dashboard/src/main/home/cluster-dashboard/expanded-chart/metrics/MetricsSection.tsx
  18. 1 1
      dashboard/src/shared/api.tsx
  19. 3 3
      internal/kubernetes/prometheus/metrics.go

+ 5 - 5
dashboard/src/components/Selector.tsx

@@ -2,12 +2,12 @@ import React, { Component } from "react";
 import styled from "styled-components";
 import { Context } from "shared/Context";
 
-export type SelectorPropsType = {
-  activeValue: string;
+export type SelectorPropsType<T> = {
+  activeValue: T;
   refreshOptions?: () => void;
-  options: { value: string; label: string; icon?: any }[];
+  options: { value: T; label: string; icon?: any }[];
   addButton?: boolean;
-  setActiveValue: (x: string) => void;
+  setActiveValue: (x: T) => void;
   width: string;
   height?: string;
   disabled?: boolean;
@@ -22,7 +22,7 @@ export type SelectorPropsType = {
 
 type StateType = {};
 
-export default class Selector extends Component<SelectorPropsType, StateType> {
+export default class Selector<T> extends Component<SelectorPropsType<T>, StateType> {
   state = {
     expanded: false,
     showTooltip: false,

+ 12 - 12
dashboard/src/components/TabSelector.tsx

@@ -1,16 +1,16 @@
 import React, { Component } from "react";
 import styled from "styled-components";
 
-export interface selectOption {
-  value: string;
+export interface selectOption<T> {
+  value: T;
   label: string;
   component?: any;
 }
 
-type PropsType = {
+type PropsType<T> = {
   currentTab: string;
-  options: selectOption[];
-  setCurrentTab: (value: string) => void;
+  options: selectOption<T>[];
+  setCurrentTab: (value: T) => void;
   addendum?: any;
   color?: string;
   noBuffer?: boolean;
@@ -18,7 +18,7 @@ type PropsType = {
 
 type StateType = {};
 
-export default class TabSelector extends Component<PropsType, StateType> {
+export default class TabSelector<T> extends Component<PropsType<T>, StateType> {
   getCurrentComponent() {
     const currentOption = this.props.options.find(
       (option) => option.value === this.props.currentTab
@@ -29,13 +29,13 @@ export default class TabSelector extends Component<PropsType, StateType> {
     return null;
   }
 
-  handleTabClick = (value: string) => {
+  handleTabClick = (value: T) => {
     this.props.setCurrentTab(value);
   };
 
   renderTabList = () => {
     let color = this.props.color || "#aaaabb";
-    return this.props.options.map((option: selectOption, i: number) => {
+    return this.props.options.map((option: selectOption<T>, i: number) => {
       return (
         <Tab
           key={i}
@@ -95,13 +95,13 @@ const TabWrapper = styled.div`
 
 const Tab = styled.div`
   height: 30px;
-  margin-right: ${(props: { lastItem: boolean; highlight: string }) =>
+  margin-right: ${(props: { lastItem: boolean; highlight: string | null }) =>
     props.lastItem ? "" : "30px"};
   display: flex;
   font-family: "Work Sans", sans-serif;
   font-size: 13px;
   user-select: none;
-  color: ${(props: { lastItem: boolean; highlight: string }) =>
+  color: ${(props: { lastItem: boolean; highlight: string | null }) =>
     props.highlight ? props.highlight : "#aaaabb55"};
   flex-direction: column;
   padding-top: 7px;
@@ -111,10 +111,10 @@ const Tab = styled.div`
   cursor: pointer;
   white-space: nowrap;
   border-bottom: 1px solid
-    ${(props: { lastItem: boolean; highlight: string }) =>
+    ${(props: { lastItem: boolean; highlight: string | null }) =>
       props.highlight ? props.highlight : "none"};
   :hover {
-    color: ${(props: { lastItem: boolean; highlight: string }) =>
+    color: ${(props: { lastItem: boolean; highlight: string | null }) =>
       props.highlight ? "" : "#aaaabb"};
   }
 `;

+ 19 - 23
dashboard/src/components/form-components/SelectRow.tsx

@@ -3,51 +3,47 @@ import styled from "styled-components";
 
 import Selector, { SelectorPropsType } from "../Selector";
 
-type PropsType = {
+type PropsType<T> = {
   label: string;
-  value: string;
-  setActiveValue: (x: string) => void;
-  options: { value: string; label: string }[];
+  value: T;
+  setActiveValue: (x: T) => void;
+  options: { value: T; label: string }[];
   dropdownLabel?: string;
   width?: string;
   dropdownMaxHeight?: string;
   scrollBuffer?: boolean;
   doc?: string;
   disabled?: boolean;
-  selectorProps?: Partial<SelectorPropsType>;
+  selectorProps?: Partial<SelectorPropsType<T>>;
 };
 
-type StateType = {};
-
-export default class SelectRow extends Component<PropsType, StateType> {
-  render() {
+export default function SelectRow<T>(props: PropsType<T>) {
     return (
       <StyledSelectRow>
         <Wrapper>
-          <Label>{this.props.label}</Label>
-          {this.props.doc ? (
-            <a href={this.props.doc} target="_blank">
+          <Label>{props.label}</Label>
+          {props.doc ? (
+            <a href={props.doc} target="_blank">
               <i className="material-icons">help_outline</i>
             </a>
           ) : null}
         </Wrapper>
         <SelectWrapper>
           <Selector
-            disabled={this.props.disabled}
-            scrollBuffer={this.props.scrollBuffer}
-            activeValue={this.props.value}
-            setActiveValue={this.props.setActiveValue}
-            options={this.props.options}
-            dropdownLabel={this.props.dropdownLabel}
-            width={this.props.width || "270px"}
-            dropdownWidth={this.props.width}
-            dropdownMaxHeight={this.props.dropdownMaxHeight}
-            {...(this.props.selectorProps || {})}
+            disabled={props.disabled}
+            scrollBuffer={props.scrollBuffer}
+            activeValue={props.value}
+            setActiveValue={props.setActiveValue}
+            options={props.options}
+            dropdownLabel={props.dropdownLabel}
+            width={props.width || "270px"}
+            dropdownWidth={props.width}
+            dropdownMaxHeight={props.dropdownMaxHeight}
+            {...(props.selectorProps || {})}
           />
         </SelectWrapper>
       </StyledSelectRow>
     );
-  }
 }
 
 const SelectWrapper = styled.div``;

+ 1 - 1
dashboard/src/main/home/app-dashboard/expanded-app/ExpandedApp.tsx

@@ -40,7 +40,7 @@ import { EnvVariablesTab } from "./env-vars/EnvVariablesTab";
 import GHABanner from "./GHABanner";
 import LogSection from "./logs/LogSection";
 import ActivityFeed from "./activity-feed/ActivityFeed";
-import MetricsSection from "./MetricsSection";
+import MetricsSection from "./metrics/MetricsSection";
 import StatusSectionFC from "./status/StatusSection";
 import ExpandedJob from "./expanded-job/ExpandedJob";
 import _ from "lodash";

+ 0 - 755
dashboard/src/main/home/app-dashboard/expanded-app/MetricsSection.tsx

@@ -1,755 +0,0 @@
-import React, { useContext, useEffect, useState } from "react";
-import styled from "styled-components";
-import ParentSize from "@visx/responsive/lib/components/ParentSize";
-
-import settings from "assets/settings.svg";
-import api from "shared/api";
-import { Context } from "shared/Context";
-import { ChartTypeWithExtendedConfig, StorageType } from "shared/types";
-
-import TabSelector from "components/TabSelector";
-import Loading from "components/Loading";
-import SelectRow from "components/form-components/SelectRow";
-import AreaChart from "../../cluster-dashboard/expanded-chart/metrics/AreaChart";
-import { MetricNormalizer } from "../../cluster-dashboard/expanded-chart/metrics/MetricNormalizer";
-import {
-  AvailableMetrics,
-  GenericMetricResponse,
-  NormalizedMetricsData,
-} from "../../cluster-dashboard/expanded-chart/metrics/types";
-import CheckboxRow from "components/form-components/CheckboxRow";
-import AggregatedDataLegend from "../../cluster-dashboard/expanded-chart/metrics/AggregatedDataLegend";
-
-type PropsType = {
-  currentChart: ChartTypeWithExtendedConfig;
-};
-
-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,
-}) => {
-  const [pods, setPods] = useState([]);
-  const [selectedPod, setSelectedPod] = useState("");
-  const [controllerOptions, setControllerOptions] = useState([]);
-  const [selectedController, setSelectedController] = useState(null);
-  const [ingressOptions, setIngressOptions] = useState([]);
-  const [selectedIngress, setSelectedIngress] = useState(null);
-  const [selectedRange, setSelectedRange] = useState("1H");
-  const [selectedMetric, setSelectedMetric] = useState("cpu");
-  const [selectedMetricLabel, setSelectedMetricLabel] = useState(
-    "CPU Utilization (vCPUs)"
-  );
-  const [dropdownExpanded, setDropdownExpanded] = useState(false);
-  const [data, setData] = useState<NormalizedMetricsData[]>([]);
-  const [aggregatedData, setAggregatedData] = useState<
-    Record<string, NormalizedMetricsData[]>
-  >({});
-  const [showMetricsSettings, setShowMetricsSettings] = useState(false);
-  const [metricsOptions, setMetricsOptions] = useState([
-    { value: "cpu", label: "CPU Utilization (vCPUs)" },
-    { value: "memory", label: "RAM Utilization (Mi)" },
-    { value: "network", label: "Network Received Bytes (Ki)" },
-  ]);
-  const [isLoading, setIsLoading] = useState(0);
-  const [hpaData, setHpaData] = useState([]);
-  const [hpaEnabled, setHpaEnabled] = useState(
-    currentChart?.config?.autoscaling?.enabled
-  );
-
-  const { currentCluster, currentProject, setCurrentError } = useContext(
-    Context
-  );
-
-  // Add or remove hpa replicas chart option when current chart is updated
-  useEffect(() => {
-    if (currentChart?.config?.autoscaling?.enabled) {
-      setMetricsOptions((prev) => {
-        if (prev.find((option) => option.value === "hpa_replicas")) {
-          return [...prev];
-        }
-        return [
-          ...prev,
-          { value: "hpa_replicas", label: "Number of replicas" },
-        ];
-      });
-    } else {
-      setMetricsOptions((prev) => {
-        const hpaReplicasOptionIndex = prev.findIndex(
-          (option) => option.value === "hpa_replicas"
-        );
-        const options = [...prev];
-        if (hpaReplicasOptionIndex > -1) {
-          options.splice(hpaReplicasOptionIndex, 1);
-        }
-        return [...options];
-      });
-    }
-  }, [currentChart]);
-
-  useEffect(() => {
-    if (currentChart?.chart?.metadata?.name == "ingress-nginx") {
-      setIsLoading((prev) => prev + 1);
-
-      api
-        .getNGINXIngresses(
-          "<token>",
-          {},
-          {
-            id: currentProject.id,
-            cluster_id: currentCluster.id,
-          }
-        )
-        .then((res) => {
-          setMetricsOptions((prev) => {
-            return [
-              ...prev,
-              {
-                value: "nginx:errors",
-                label: "5XX Error Percentage",
-              },
-            ];
-          });
-
-          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(
-        "<token>",
-        {},
-        {
-          id: currentProject.id,
-          name: currentChart.name,
-          namespace: currentChart.namespace,
-          cluster_id: currentCluster.id,
-          revision: currentChart.version,
-        }
-      )
-      .then((res) => {
-        const controllerOptions = res.data.map((controller: any) => {
-          let name = controller?.metadata?.name;
-          return { value: controller, label: name };
-        });
-
-        setControllerOptions(controllerOptions);
-        setSelectedController(controllerOptions[0]?.value);
-      })
-      .catch((err) => {
-        setCurrentError(JSON.stringify(err));
-        setControllerOptions([]);
-      })
-      .finally(() => {
-        setIsLoading((prev) => prev - 1);
-      });
-  }, [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;
-    }
-
-    selectors.push(selector);
-
-    if (selectors[0] === "") {
-      return;
-    }
-
-    setIsLoading((prev) => prev + 1);
-
-    api
-      .getMatchingPods(
-        "<token>",
-        {
-          namespace: selectedController?.metadata?.namespace,
-          selectors,
-        },
-        {
-          id: currentProject.id,
-          cluster_id: currentCluster.id,
-        }
-      )
-      .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);
-        setSelectedPod("All");
-
-        getMetrics();
-      })
-      .catch((err) => {
-        setCurrentError(JSON.stringify(err));
-        return;
-      })
-      .finally(() => {
-        setIsLoading((prev) => prev - 1);
-      });
-  };
-
-  const getAutoscalingThreshold = async (
-    metricType: "cpu_hpa_threshold" | "memory_hpa_threshold",
-    shouldsum: boolean,
-    namespace: string,
-    start: number,
-    end: number
-  ) => {
-    setIsLoading((prev) => prev + 1);
-    setHpaData([]);
-    try {
-      const res = await api.getMetrics(
-        "<token>",
-        {
-          metric: metricType,
-          shouldsum: shouldsum,
-          kind: selectedController?.kind,
-          name: selectedController?.metadata.name,
-          namespace: namespace,
-          startrange: start,
-          endrange: end,
-          resolution: resolutions[selectedRange],
-          pods: [],
-        },
-        {
-          id: currentProject.id,
-          cluster_id: currentCluster.id,
-        }
-      );
-
-      if (!Array.isArray(res.data) || !res.data[0]?.results) {
-        return;
-      }
-      const autoscalingMetrics = new MetricNormalizer(res.data, metricType);
-      setHpaData(autoscalingMetrics.getParsedData());
-      return;
-    } catch (error) {
-      console.error(error);
-    } finally {
-      setIsLoading((prev) => prev - 1);
-    }
-  };
-
-  const getMetrics = async () => {
-    if (pods?.length == 0) {
-      return;
-    }
-    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;
-      }
-
-      setIsLoading((prev) => prev + 1);
-      setData([]);
-      setAggregatedData({});
-
-      // Get aggregated metrics
-      const allPodsRes = await api.getMetrics(
-        "<token>",
-        {
-          metric: selectedMetric,
-          shouldsum: false,
-          kind: selectedController?.kind,
-          name: selectedController?.metadata.name,
-          namespace: namespace,
-          startrange: start,
-          endrange: end,
-          resolution: resolutions[selectedRange],
-          pods: [],
-        },
-        {
-          id: currentProject.id,
-          cluster_id: currentCluster.id,
-        }
-      );
-
-      const allPodsData: GenericMetricResponse[] = allPodsRes.data ?? [];
-      const allPodsMetrics = allPodsData.flatMap((d) => d.results);
-      const allPodsMetricsNormalized = new MetricNormalizer(
-        [{ results: allPodsMetrics }],
-        selectedMetric as AvailableMetrics
-      );
-      setAggregatedData(allPodsMetricsNormalized.getAggregatedData());
-      //
-
-      const res = await api.getMetrics(
-        "<token>",
-        {
-          metric: selectedMetric,
-          shouldsum: shouldsum,
-          kind: selectedController?.kind,
-          name: selectedController?.metadata.name,
-          namespace: namespace,
-          startrange: start,
-          endrange: end,
-          resolution: resolutions[selectedRange],
-          pods: podNames,
-        },
-        {
-          id: currentProject.id,
-          cluster_id: currentCluster.id,
-        }
-      );
-
-      setHpaData([]);
-      const isHpaEnabled = currentChart?.config?.autoscaling?.enabled;
-      if (shouldsum && isHpaEnabled) {
-        if (selectedMetric === "cpu") {
-          await getAutoscalingThreshold(
-            "cpu_hpa_threshold",
-            shouldsum,
-            namespace,
-            start,
-            end
-          );
-        } else if (selectedMetric === "memory") {
-          await getAutoscalingThreshold(
-            "memory_hpa_threshold",
-            shouldsum,
-            namespace,
-            start,
-            end
-          );
-        }
-      }
-
-      const metrics = new MetricNormalizer(
-        res.data,
-        selectedMetric as AvailableMetrics
-      );
-
-      // transform the metrics to expected form
-      setData(metrics.getParsedData());
-    } catch (error) {
-      setCurrentError(JSON.stringify(error));
-    } finally {
-      setIsLoading((prev) => prev - 1);
-    }
-  };
-
-  useEffect(() => {
-    if (selectedMetric && selectedRange && selectedPod && selectedController) {
-      getMetrics();
-    }
-  }, [
-    selectedMetric,
-    selectedRange,
-    selectedPod,
-    selectedController,
-    selectedIngress,
-  ]);
-
-  const renderMetricsSettings = () => {
-    if (showMetricsSettings && true) {
-      if (selectedMetric == "nginx:errors") {
-        return (
-          <>
-            <DropdownOverlay onClick={() => setShowMetricsSettings(false)} />
-            <DropdownAlt dropdownWidth="330px" dropdownMaxHeight="300px">
-              <Label>Additional Settings</Label>
-              <SelectRow
-                label="Target Ingress"
-                value={selectedIngress}
-                setActiveValue={(x: any) => setSelectedIngress(x)}
-                options={ingressOptions}
-                width="100%"
-              />
-            </DropdownAlt>
-          </>
-        );
-      }
-
-      return (
-        <>
-          <DropdownOverlay onClick={() => setShowMetricsSettings(false)} />
-          <DropdownAlt dropdownWidth="330px" dropdownMaxHeight="300px">
-            <Label>Additional Settings</Label>
-            <SelectRow
-              label="Target Controller"
-              value={selectedController}
-              setActiveValue={(x: any) => setSelectedController(x)}
-              options={controllerOptions}
-              width="100%"
-            />
-            <SelectRow
-              label="Target Pod"
-              value={selectedPod}
-              setActiveValue={(x: any) => setSelectedPod(x)}
-              options={pods}
-              width="100%"
-            />
-          </DropdownAlt>
-        </>
-      );
-    }
-  };
-
-  const renderDropdown = () => {
-    if (dropdownExpanded) {
-      return (
-        <>
-          <DropdownOverlay onClick={() => setDropdownExpanded(false)} />
-          <Dropdown
-            dropdownWidth="230px"
-            dropdownMaxHeight="200px"
-            onClick={() => setDropdownExpanded(false)}
-          >
-            {renderOptionList()}
-          </Dropdown>
-        </>
-      );
-    }
-  };
-
-  const renderOptionList = () => {
-    return metricsOptions.map(
-      (option: { value: string; label: string }, i: number) => {
-        return (
-          <Option
-            key={i}
-            selected={option.value === selectedMetric}
-            onClick={() => {
-              setSelectedMetric(option.value);
-              setSelectedMetricLabel(option.label);
-            }}
-            lastItem={i === metricsOptions.length - 1}
-          >
-            {option.label}
-          </Option>
-        );
-      }
-    );
-  };
-
-  return (
-    <StyledMetricsSection>
-      <MetricsHeader>
-        <Flex>
-          <MetricSelector
-            onClick={() => setDropdownExpanded(!dropdownExpanded)}
-          >
-            <MetricsLabel>{selectedMetricLabel}</MetricsLabel>
-            <i className="material-icons">arrow_drop_down</i>
-            {renderDropdown()}
-          </MetricSelector>
-          <Relative>
-            <IconWrapper onClick={() => setShowMetricsSettings(true)}>
-              <SettingsIcon src={settings} />
-            </IconWrapper>
-            {renderMetricsSettings()}
-          </Relative>
-
-          <Highlight color={"#7d7d81"} onClick={getMetrics}>
-            <i className="material-icons">autorenew</i>
-          </Highlight>
-        </Flex>
-        <RangeWrapper>
-          <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>
-      {isLoading > 0 && <Loading />}
-      {data.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 && (
-        <>
-          {currentChart?.config?.autoscaling?.enabled &&
-            ["cpu", "memory"].includes(selectedMetric) && (
-              <CheckboxRow
-                toggle={() => setHpaEnabled((prev: any) => !prev)}
-                checked={hpaEnabled}
-                label="Show Autoscaling Threshold"
-              />
-            )}
-          <ParentSize>
-            {({ width, height }) => (
-              <AreaChart
-                dataKey={selectedMetricLabel}
-                aggregatedData={aggregatedData}
-                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} />
-          </RowWrapper>
-        </>
-      )}
-    </StyledMetricsSection>
-  );
-};
-
-export default MetricsSection;
-
-const RowWrapper = styled.div`
-  width: 100%;
-  display: flex;
-  justify-content: flex-end;
-`;
-
-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 Label = styled.div`
-  font-weight: bold;
-`;
-
-const Relative = styled.div`
-  position: relative;
-`;
-
-const Message = styled.div`
-  display: flex;
-  height: 100%;
-  width: calc(100% - 150px);
-  align-items: center;
-  justify-content: center;
-  margin-left: 75px;
-  text-align: center;
-  color: #ffffff44;
-  font-size: 13px;
-`;
-
-const IconWrapper = styled.div`
-  display: flex;
-  position: relative;
-  align-items: center;
-  justify-content: center;
-  margin-top: 2px;
-  border-radius: 30px;
-  height: 25px;
-  width: 25px;
-  margin-left: 8px;
-  cursor: pointer;
-  :hover {
-    background: #ffffff22;
-  }
-`;
-
-const SettingsIcon = styled.img`
-  opacity: 0.4;
-  width: 20px;
-  height: 20px;
-  margin-left: -1px;
-  margin-bottom: -2px;
-`;
-
-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 DropdownOverlay = styled.div`
-  position: fixed;
-  width: 100%;
-  height: 100%;
-  z-index: 10;
-  left: 0px;
-  top: 0px;
-  cursor: default;
-`;
-
-const Option = styled.div`
-  width: 100%;
-  border-top: 1px solid #00000000;
-  border-bottom: 1px solid
-    ${(props: { selected: boolean; lastItem: boolean }) =>
-      props.lastItem ? "#ffffff00" : "#ffffff15"};
-  height: 37px;
-  font-size: 13px;
-  padding-top: 9px;
-  align-items: center;
-  padding-left: 15px;
-  cursor: pointer;
-  padding-right: 10px;
-  white-space: nowrap;
-  overflow: hidden;
-  text-overflow: ellipsis;
-  background: ${(props: { selected: boolean; lastItem: boolean }) =>
-    props.selected ? "#ffffff11" : ""};
-
-  :hover {
-    background: #ffffff22;
-  }
-`;
-
-const Dropdown = styled.div`
-  position: absolute;
-  left: 0;
-  top: calc(100% + 10px);
-  background: #26282f;
-  width: ${(props: { dropdownWidth: string; dropdownMaxHeight: string }) =>
-    props.dropdownWidth};
-  max-height: ${(props: { dropdownWidth: string; dropdownMaxHeight: string }) =>
-    props.dropdownMaxHeight || "300px"};
-  border-radius: 3px;
-  z-index: 999;
-  overflow-y: auto;
-  margin-bottom: 20px;
-  box-shadow: 0px 4px 10px 0px #00000088;
-`;
-
-const DropdownAlt = styled(Dropdown)`
-  padding: 20px 20px 7px;
-  overflow: visible;
-`;
-
-const RangeWrapper = styled.div`
-  float: right;
-  font-weight: bold;
-  width: 158px;
-  margin-top: -8px;
-`;
-
-const MetricSelector = styled.div`
-  font-size: 13px;
-  font-weight: 500;
-  position: relative;
-  color: #ffffff;
-  display: flex;
-  align-items: center;
-  cursor: pointer;
-  border-radius: 5px;
-  :hover {
-    > i {
-      background: #ffffff22;
-    }
-  }
-
-  > i {
-    border-radius: 20px;
-    font-size: 20px;
-    margin-left: 10px;
-  }
-`;
-
-const MetricsLabel = styled.div`
-  white-space: nowrap;
-  text-overflow: ellipsis;
-  overflow: hidden;
-  max-width: 200px;
-`;
-
-const StyledMetricsSection = styled.div`
-  width: 100%;
-  min-height: 480px;
-  height: calc(100vh - 350px);
-  display: flex;
-  flex-direction: column;
-  position: relative;
-  font-size: 13px;
-  animation: floatIn 0.3s;
-  animation-timing-function: ease-out;
-  animation-fill-mode: forwards;
-  @keyframes floatIn {
-    from {
-      opacity: 0;
-      transform: translateY(10px);
-    }
-    to {
-      opacity: 1;
-      transform: translateY(0px);
-    }
-  }
-`;

+ 520 - 0
dashboard/src/main/home/app-dashboard/expanded-app/metrics/AreaChart.tsx

@@ -0,0 +1,520 @@
+import _ from "lodash";
+import React, { useCallback, useMemo, useRef } from "react";
+import chroma from "chroma-js";
+import * as stats from "simple-statistics";
+import styled from "styled-components";
+import { AreaClosed, Bar, Line, LinePath } from "@visx/shape";
+import { curveMonotoneX } from "@visx/curve";
+import { scaleLinear, scaleTime } from "@visx/scale";
+import { AxisBottom, AxisLeft } from "@visx/axis";
+
+import { defaultStyles, TooltipWithBounds, useTooltip } from "@visx/tooltip";
+
+import { GridColumns, GridRows } from "@visx/grid";
+
+import { localPoint } from "@visx/event";
+import { LinearGradient } from "@visx/gradient";
+import { bisector, extent, max } from "d3-array";
+import { timeFormat } from "d3-time-format";
+import { NormalizedMetricsData } from "./types";
+import { pickColor } from "./utils";
+
+var globalData: NormalizedMetricsData[];
+
+export const background = "#3b697800";
+export const background2 = "#20405100";
+export const accentColor = "#949eff";
+export const accentColorDark = "#949eff";
+
+// util
+const formatDate = timeFormat("%H:%M:%S %b %d, '%y");
+
+const hourFormat = timeFormat("%H:%M");
+const dayFormat = timeFormat("%b %d");
+
+// map resolutions to formats
+const formats: { [range: string]: (date: Date) => string } = {
+  "1H": hourFormat,
+  "6H": hourFormat,
+  "1D": hourFormat,
+  "1M": dayFormat,
+};
+
+// accessors
+const getDate = (d: NormalizedMetricsData) => new Date(d.date * 1000);
+const getValue = (d: NormalizedMetricsData) =>
+  d?.value && Number(d.value?.toFixed(4));
+
+const bisectDate = bisector<NormalizedMetricsData, Date>(
+  (d) => new Date(d.date * 1000)
+).left;
+
+export type AreaProps = {
+  data: NormalizedMetricsData[];
+  aggregatedData?: Record<string, NormalizedMetricsData[]>;
+  dataKey: string;
+  hpaEnabled?: boolean;
+  hpaData?: NormalizedMetricsData[];
+  resolution: string;
+  width: number;
+  height: number;
+  margin?: { top: number; right: number; bottom: number; left: number };
+};
+
+const AreaChart: React.FunctionComponent<AreaProps> = ({
+  data,
+  aggregatedData = {},
+  dataKey,
+  hpaEnabled = false,
+  hpaData = [],
+  resolution,
+  width,
+  height,
+  margin = { top: 0, right: 0, bottom: 0, left: 0 },
+}) => {
+  globalData = data;
+
+  console.log(aggregatedData);
+
+  const {
+    showTooltip,
+    hideTooltip,
+    tooltipData,
+    tooltipTop,
+    tooltipLeft,
+  } = useTooltip<{
+    data: NormalizedMetricsData;
+    tooltipHpaData: NormalizedMetricsData;
+    aggregatedData?: Record<string, NormalizedMetricsData>;
+  }>();
+
+  const svgContainer = useRef();
+  // bounds
+  const innerWidth = width - margin.left - margin.right - 40;
+  const innerHeight = height - margin.top - margin.bottom - 20;
+  const isHpaEnabled = hpaEnabled && !!hpaData.length;
+
+  // scales
+  const dateScale = useMemo(
+    () =>
+      scaleTime({
+        range: [margin.left, innerWidth + margin.left],
+        domain: extent(
+          [...globalData, ...(isHpaEnabled ? hpaData : [])],
+          getDate
+        ) as [Date, Date],
+      }),
+    [margin.left, width, height, data, hpaData, isHpaEnabled]
+  );
+  const valueScale = useMemo(
+    () =>
+      scaleLinear({
+        range: [innerHeight + margin.top, margin.top],
+        domain: [
+          0,
+          1.25 *
+            max(
+              [
+                ...globalData,
+                ...Object.values(aggregatedData).flat(),
+                ...(isHpaEnabled ? hpaData : []),
+              ],
+              getValue
+            ),
+        ],
+        nice: true,
+      }),
+    [margin.top, width, height, data, hpaData, isHpaEnabled]
+  );
+
+  const getAggregatedDataTooltip = (x0: Date) => {
+    let aggregatedTooltipData: Record<string, NormalizedMetricsData> = {};
+    for (let [key, values] of Object.entries(aggregatedData)) {
+      const index = bisectDate(values, x0, 1);
+      const d0 = values[index - 1];
+      const d1 = values[index];
+      let d = d0;
+
+      if (d1 && getDate(d1)) {
+        d =
+          x0.valueOf() - getDate(d0).valueOf() >
+          getDate(d1).valueOf() - x0.valueOf()
+            ? d1
+            : d0;
+      }
+
+      aggregatedTooltipData[key] = d;
+    }
+
+    return aggregatedTooltipData;
+  };
+
+  // tooltip handler
+  const handleTooltip = useCallback(
+    (
+      event: React.TouchEvent<SVGRectElement> | React.MouseEvent<SVGRectElement>
+    ) => {
+      const isHpaEnabled = hpaEnabled && !!hpaData.length;
+
+      const { x } = localPoint(event) || { x: 0 };
+      const x0 = dateScale.invert(x);
+
+      const index = bisectDate(globalData, x0, 1);
+      const d0 = globalData[index - 1];
+      const d1 = globalData[index];
+      let d = d0;
+
+      if (d1 && getDate(d1)) {
+        d =
+          x0.valueOf() - getDate(d0).valueOf() >
+          getDate(d1).valueOf() - x0.valueOf()
+            ? d1
+            : d0;
+      }
+
+      const hpaIndex = bisectDate(hpaData, x0, 1);
+      // Get new index without min value to be sure that data exists for HPA
+      const hpaIndex2 = bisectDate(hpaData, x0);
+
+      if (!isHpaEnabled || hpaIndex !== hpaIndex2) {
+        showTooltip({
+          tooltipData: {
+            data: d,
+            tooltipHpaData: undefined,
+            aggregatedData: getAggregatedDataTooltip(x0),
+          },
+          tooltipLeft: x || 0,
+          tooltipTop: valueScale(getValue(d)) || 0,
+        });
+        return;
+      }
+
+      const tooltipHpaData0 = hpaData[hpaIndex - 1];
+      const tooltipHpaData1 = hpaData[hpaIndex];
+      let tooltipHpaData = tooltipHpaData0;
+
+      if (tooltipHpaData1 && getDate(tooltipHpaData1)) {
+        tooltipHpaData =
+          x0.valueOf() - getDate(tooltipHpaData0).valueOf() >
+          getDate(tooltipHpaData1).valueOf() - x0.valueOf()
+            ? tooltipHpaData1
+            : tooltipHpaData0;
+      }
+
+      const container: SVGSVGElement = svgContainer.current;
+
+      let point = container.createSVGPoint();
+      // @ts-ignore
+      point.x = (event as any)?.clientX || 0;
+      // @ts-ignore
+      point.y = (event as any)?.clientY || 0;
+      point = point?.matrixTransform(container.getScreenCTM().inverse());
+
+      showTooltip({
+        tooltipData: {
+          data: d,
+          tooltipHpaData,
+          aggregatedData: getAggregatedDataTooltip(x0),
+        },
+        tooltipLeft: x || 0,
+        tooltipTop: point.y || 0,
+      });
+    },
+    [
+      showTooltip,
+      valueScale,
+      dateScale,
+      width,
+      height,
+      data,
+      hpaData,
+      svgContainer,
+      hpaEnabled,
+    ]
+  );
+
+  if (width == 0 || height == 0 || width < 10) {
+    return null;
+  }
+  const hpaGraphTooltipGlyphPosition =
+    (hpaEnabled &&
+      tooltipData?.tooltipHpaData &&
+      valueScale(getValue(tooltipData?.tooltipHpaData))) ||
+    null;
+
+  const dataGraphTooltipGlyphPosition =
+    (tooltipData?.data && valueScale(getValue(tooltipData.data))) || 0;
+
+  return (
+    <div>
+      <svg width={width} height={height} ref={svgContainer}>
+        <rect
+          x={0}
+          y={0}
+          width={width}
+          height={height}
+          fill="url(#area-background-gradient)"
+          rx={14}
+        />
+
+        <LinearGradient
+          id="area-background-gradient"
+          from={background}
+          to={background2}
+        />
+        <LinearGradient
+          id="area-gradient"
+          from={accentColor}
+          to={accentColor}
+          toOpacity={0}
+        />
+        {Object.entries(aggregatedData).map(([key, data], i) => (
+          <LinearGradient
+            id={`area-gradient-${key}`}
+            from={pickColor(
+              "#4C4F6B",
+              "#B04649",
+              i,
+              Object.entries(aggregatedData).length
+            )}
+            to={pickColor(
+              "#4C4F6B",
+              "#B04649",
+              i,
+              Object.entries(aggregatedData).length
+            )}
+            toOpacity={0}
+          />
+        ))}
+        <GridRows
+          left={margin.left}
+          scale={valueScale}
+          width={innerWidth}
+          strokeDasharray="1,3"
+          stroke="white"
+          strokeOpacity={0.2}
+          pointerEvents="none"
+        />
+        <GridColumns
+          top={margin.top}
+          scale={dateScale}
+          height={innerHeight}
+          strokeDasharray="1,3"
+          stroke="white"
+          strokeOpacity={0.2}
+          pointerEvents="none"
+        />
+        {Object.entries(aggregatedData).map(([key, data], i) => (
+          <>
+            <LinePath<NormalizedMetricsData>
+              data={data}
+              x={(d) => dateScale(getDate(d)) ?? 0}
+              y={(d) => valueScale(getValue(d)) ?? 0}
+              height={innerHeight}
+              strokeWidth={1}
+              stroke={pickColor(
+                "#4C4F6B",
+                "#B04649",
+                i,
+                Object.entries(aggregatedData).length
+              )}
+              curve={curveMonotoneX}
+            />
+            <AreaClosed<NormalizedMetricsData>
+              data={data}
+              x={(d) => dateScale(getDate(d)) ?? 0}
+              y={(d) => valueScale(getValue(d)) ?? 0}
+              height={innerHeight}
+              yScale={valueScale}
+              strokeWidth={1}
+              fill={`url(#area-gradient-${key})`}
+              fill={`url(#area-gradient-${key})`}
+              curve={curveMonotoneX}
+            />
+          </>
+        ))}
+        {isHpaEnabled && (
+          <LinePath<NormalizedMetricsData>
+            stroke="#ffffff"
+            strokeWidth={2}
+            data={hpaData}
+            x={(d) => dateScale(getDate(d)) ?? 0}
+            y={(d) => valueScale(getValue(d)) ?? 0}
+            strokeDasharray="6,4"
+            strokeOpacity={1}
+            pointerEvents="none"
+          />
+        )}
+
+        <AxisLeft
+          left={10}
+          scale={valueScale}
+          hideAxisLine={true}
+          hideTicks={true}
+          tickLabelProps={() => ({
+            fill: "white",
+            fontSize: 11,
+            textAnchor: "start",
+            fillOpacity: 0.4,
+            dy: 0,
+          })}
+        />
+        <AxisBottom
+          top={height - 20}
+          scale={dateScale}
+          tickFormat={formats[resolution]}
+          hideAxisLine={true}
+          hideTicks={true}
+          tickLabelProps={() => ({
+            fill: "white",
+            fontSize: 11,
+            textAnchor: "middle",
+            fillOpacity: 0.4,
+          })}
+        />
+        <Bar
+          x={margin.left}
+          y={margin.top}
+          width={innerWidth}
+          height={innerHeight}
+          fill="transparent"
+          rx={14}
+          onTouchStart={handleTooltip}
+          onTouchMove={handleTooltip}
+          onMouseMove={handleTooltip}
+          onMouseLeave={() => hideTooltip()}
+        />
+        {tooltipData && (
+          <g>
+            <Line
+              from={{ x: tooltipLeft, y: margin.top }}
+              to={{ x: tooltipLeft, y: innerHeight + margin.top }}
+              stroke={accentColorDark}
+              strokeWidth={2}
+              pointerEvents="none"
+              strokeDasharray="5,2"
+            />
+            <circle
+              cx={tooltipLeft}
+              cy={dataGraphTooltipGlyphPosition + 1}
+              r={4}
+              fill="black"
+              fillOpacity={0.1}
+              stroke="black"
+              strokeOpacity={0.1}
+              strokeWidth={2}
+              pointerEvents="none"
+            />
+            {Object.values(tooltipData.aggregatedData)?.map((d) => (
+              <circle
+                cx={tooltipLeft}
+                cy={valueScale(getValue(d)) + 1}
+                r={4}
+                fill="black"
+                fillOpacity={0.1}
+                stroke="black"
+                strokeOpacity={0.1}
+                strokeWidth={2}
+                pointerEvents="none"
+              />
+            ))}
+            <circle
+              cx={tooltipLeft}
+              cy={dataGraphTooltipGlyphPosition}
+              r={4}
+              fill={accentColorDark}
+              stroke="white"
+              strokeWidth={2}
+              pointerEvents="none"
+            />
+            {Object.values(tooltipData.aggregatedData)?.map((d) => (
+              <circle
+                cx={tooltipLeft}
+                cy={valueScale(getValue(d))}
+                r={4}
+                fill={accentColorDark}
+                stroke="white"
+                strokeWidth={2}
+                pointerEvents="none"
+              />
+            ))}
+            {isHpaEnabled && hpaGraphTooltipGlyphPosition !== null && (
+              <>
+                <circle
+                  cx={tooltipLeft}
+                  cy={hpaGraphTooltipGlyphPosition + 1}
+                  r={4}
+                  fill="black"
+                  fillOpacity={0.1}
+                  stroke="black"
+                  strokeOpacity={0.1}
+                  strokeWidth={2}
+                  pointerEvents="none"
+                />
+                <circle
+                  cx={tooltipLeft}
+                  cy={hpaGraphTooltipGlyphPosition}
+                  r={4}
+                  fill={accentColorDark}
+                  stroke="white"
+                  strokeWidth={2}
+                  pointerEvents="none"
+                />
+              </>
+            )}
+          </g>
+        )}
+      </svg>
+      {tooltipData && (
+        <div>
+          <TooltipWithBounds
+            key={Math.random()}
+            top={tooltipTop - 12}
+            left={tooltipLeft + 12}
+            style={{
+              ...defaultStyles,
+              background: "#26272f",
+              color: "#aaaabb",
+            }}
+          >
+            <TooltipDate>{formatDate(getDate(tooltipData.data))}</TooltipDate>
+            <TooltipDataRow>
+              {dataKey}: {getValue(tooltipData.data)}
+            </TooltipDataRow>
+            {Object.entries(tooltipData.aggregatedData).map(
+              ([key, value], i) => (
+                <TooltipDataRow
+                  color={pickColor(
+                    "#4C4F6B",
+                    "#B04649",
+                    i,
+                    Object.entries(aggregatedData).length
+                  )}
+                >
+                  {`${key.toUpperCase()}. ${dataKey}`}: {getValue(value)}
+                </TooltipDataRow>
+              )
+            )}
+            {isHpaEnabled && hpaGraphTooltipGlyphPosition !== null && (
+              <div style={{ color: "#FFF" }}>
+                Autoscaling Threshold: {getValue(tooltipData.tooltipHpaData)}
+              </div>
+            )}
+          </TooltipWithBounds>
+        </div>
+      )}
+    </div>
+  );
+};
+
+export default AreaChart;
+
+const TooltipDate = styled.div`
+  text-align: center;
+  margin-bottom: 8px;
+`;
+
+const TooltipDataRow = styled.div<{ color?: string }>`
+  color: ${(props) => props.color ?? accentColor};
+  margin-bottom: 4px;
+`;

+ 53 - 0
dashboard/src/main/home/app-dashboard/expanded-app/metrics/DataLegend.tsx

@@ -0,0 +1,53 @@
+import React from "react";
+import * as stats from "simple-statistics";
+import styled from "styled-components";
+import chroma from "chroma-js";
+import { NormalizedMetricsData } from "./types";
+import { AggregatedDataColors, pickColor } from "./utils";
+
+interface AggregatedDataLegendProps {
+  data: Record<string, number>;
+}
+
+const DataLegend = ({ data }: AggregatedDataLegendProps) => {
+  return (
+    <AggregatedDataContainer>
+      {Object.entries(data).map(([key, value], i) => (
+        <AggregatedDataItem>
+          <DataBar
+            color={pickColor(
+              "#4C4F6B",
+              "#B04649",
+              i,
+              Object.entries(data).length
+            )}
+          />
+          {key}: {Math.round(value * 10000) / 10000}
+        </AggregatedDataItem>
+      ))}
+    </AggregatedDataContainer>
+  );
+};
+
+export default DataLegend;
+
+const AggregatedDataContainer = styled.div`
+  display: flex;
+  margin-block: 8px;
+`;
+
+const AggregatedDataItem = styled.div`
+  display: flex;
+  flex-direction: row;
+  height: 20px;
+  align-items: center;
+  gap: 4px;
+`;
+
+const DataBar = styled.div<{ color: string }>`
+  height: 10px;
+  width: 10px;
+  margin-left: 10px;
+  border: 1px solid ${(props) => props.color};
+  background-color: ${(props) => chroma(props.color).alpha(0.6).css()};
+`;

+ 150 - 0
dashboard/src/main/home/app-dashboard/expanded-app/metrics/MetricNormalizer.ts

@@ -0,0 +1,150 @@
+import * as stats from "simple-statistics";
+import _ from "lodash";
+import {
+  GenericMetricResponse,
+  MetricsCPUDataResponse,
+  MetricsMemoryDataResponse,
+  MetricsNetworkDataResponse,
+  MetricsNGINXErrorsDataResponse,
+  AvailableMetrics,
+  MetricsHpaReplicasDataResponse,
+  MetricsNGINXLatencyDataResponse,
+  NormalizedMetricsData,
+} from "./types";
+
+/**
+ * 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 [];
+  }
+
+  getAggregatedData(): 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 {
+      min,
+      avg,
+      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 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),
+      };
+    });
+  }
+}

+ 519 - 0
dashboard/src/main/home/app-dashboard/expanded-app/metrics/MetricsBase.tsx

@@ -0,0 +1,519 @@
+import React, { useContext, useEffect, useState } from "react";
+import ParentSize from "@visx/responsive/lib/components/ParentSize";
+
+import api from "shared/api";
+
+import TabSelector from "components/TabSelector";
+import Loading from "components/Loading";
+import AreaChart from "./AreaChart";
+import { MetricNormalizer } from "./MetricNormalizer";
+import {
+  AvailableMetrics,
+  GenericMetricResponse,
+  NormalizedMetricsData,
+  Service,
+  resolutions,
+  secondsBeforeNow,
+  AvailableTimeRanges,
+  MetricOptions,
+  DataGrouping,
+  AggregationMethod,
+  AggregationDetails,
+} from "./types";
+import {
+  RowWrapper,
+  Highlight,
+  Message,
+  Flex,
+  MetricsHeader,
+  DropdownOverlay,
+  Option,
+  Dropdown,
+  RangeWrapper,
+  MetricSelector,
+  MetricsLabel,
+  StyledMetricsSection,
+} from "./styles";
+import CheckboxRow from "components/form-components/CheckboxRow";
+import DataLegend from "./DataLegend";
+import Spacer from "../../../../../components/porter/Spacer";
+import * as stats from "simple-statistics";
+
+type PropsType = {
+  services: Service[];
+  timeRangeOptions: AvailableTimeRanges[];
+  initialMetricsOptions: AvailableMetrics[];
+  autoscaling_enabled: boolean;
+  project_id: number;
+  cluster_id: number;
+};
+
+const MetricsBase: React.FunctionComponent<PropsType> = ({
+  services,
+  timeRangeOptions = ["1H", "6H", "1D", "1M"],
+  initialMetricsOptions = ["cpu", "memory", "network"],
+  autoscaling_enabled = false,
+  project_id,
+  cluster_id,
+}) => {
+  const metricsLabelMap: Record<AvailableMetrics, MetricOptions> = {
+    cpu: MetricOptions.default("CPU Utilization (vCPUs)"),
+    memory: MetricOptions.default("RAM Utilization (Mi)"),
+    network: MetricOptions.default("Network Received Bytes (Ki)"),
+    hpa_replicas: MetricOptions.default("Number of replicas"),
+    "nginx:errors": MetricOptions.default("5XX Error Percentage"),
+    "nginx:latency": MetricOptions.default("Latency (ms)"),
+    "nginx:latency-histogram": MetricOptions.default(
+      "Percentile Response Times (s)",
+      true
+    ),
+    cpu_hpa_threshold: MetricOptions.default(""),
+    memory_hpa_threshold: MetricOptions.default(""),
+  };
+
+  const aggregationMap: Record<AggregationMethod, AggregationDetails> = {
+    min: {
+      label: "MIN",
+      aggregationFunc: stats.min,
+    },
+    avg: {
+      label: "AVG",
+      aggregationFunc: stats.average,
+    },
+    max: {
+      label: "MAX",
+      aggregationFunc: stats.max,
+    },
+  };
+
+  const [metricsOptions, setMetricsOptions] = useState<AvailableMetrics[]>(
+    initialMetricsOptions
+  );
+  const [selectedService, setSelectedService] = useState<Service>();
+  const [selectedRange, setSelectedRange] = useState<AvailableTimeRanges>();
+  const [selectedMetric, setSelectedMetric] = useState<AvailableMetrics>();
+  const [metricDropdownExpanded, setMetricDropdownExpanded] = useState(false);
+  const [serviceDropdownExpanded, setServiceDropdownExpanded] = useState(false);
+  const [individualData, setIndividualData] = useState<
+    Record<string, NormalizedMetricsData[]>
+  >({});
+  const [aggregatedData, setAggregatedData] = useState<
+    Record<string, NormalizedMetricsData[]>
+  >({});
+  const [isLoading, setIsLoading] = useState(0);
+  const [hpaData, setHpaData] = useState<NormalizedMetricsData[]>([]);
+  const [showHPA, setShowHPA] = useState(autoscaling_enabled);
+  const [dataGrouping, setDataGrouping] = useState<DataGrouping>("aggregate");
+  const [
+    individualAggregationMethod,
+    setIndividualAggregationMethod,
+  ] = useState<AggregationMethod>("avg");
+
+  // Add or remove hpa replicas chart option when current chart is updated
+  useEffect(() => {
+    if (autoscaling_enabled) {
+      setMetricsOptions((prev) => {
+        if (prev.find((option) => option === "hpa_replicas")) {
+          return [...prev];
+        }
+        return [...prev, "hpa_replicas"];
+      });
+    } else {
+      setMetricsOptions((prev) => {
+        const hpaReplicasOptionIndex = prev.findIndex(
+          (option) => option === "hpa_replicas"
+        );
+        const options = [...prev];
+        if (hpaReplicasOptionIndex > -1) {
+          options.splice(hpaReplicasOptionIndex, 1);
+        }
+        return [...options];
+      });
+    }
+
+    if (metricsOptions.length > 0) {
+      setSelectedMetric(metricsOptions[0]);
+    }
+    if (services.length > 0) {
+      setSelectedService(services[0]);
+    }
+    if (timeRangeOptions.length > 0) {
+      setSelectedRange(timeRangeOptions[0]);
+    }
+  }, [
+    services,
+    timeRangeOptions,
+    initialMetricsOptions,
+    autoscaling_enabled,
+    project_id,
+    cluster_id,
+  ]);
+
+  useEffect(() => {
+    if (selectedMetric && selectedRange && selectedService) {
+      getMetrics();
+    }
+  }, [selectedMetric, selectedRange, selectedService]);
+
+  const getAutoscalingThreshold = async (
+    metricType: "cpu_hpa_threshold" | "memory_hpa_threshold",
+    shouldavg: boolean,
+    namespace: string,
+    start: number,
+    end: number
+  ) => {
+    setHpaData([]);
+    if (
+      selectedService == null ||
+      selectedRange == null ||
+      selectedMetric == null
+    ) {
+      return;
+    }
+    setIsLoading((prev) => prev + 1);
+
+    try {
+      const res = await api.getMetrics(
+        "<token>",
+        {
+          metric: metricType,
+          shouldavg: shouldavg,
+          kind: selectedService.kind,
+          name: selectedService.name,
+          namespace: namespace,
+          startrange: start,
+          endrange: end,
+          resolution: resolutions[selectedRange],
+          pods: [],
+        },
+        {
+          id: project_id,
+          cluster_id: cluster_id,
+        }
+      );
+
+      if (!Array.isArray(res.data) || !res.data[0]?.results) {
+        return;
+      }
+      const autoscalingMetrics = new MetricNormalizer(res.data, metricType);
+      setHpaData(autoscalingMetrics.getParsedData());
+      return;
+    } catch (error) {
+      console.error(error);
+    } finally {
+      setIsLoading((prev) => prev - 1);
+    }
+  };
+
+  const getMetrics = async () => {
+    if (
+      selectedService == null ||
+      selectedMetric == null ||
+      selectedRange == null
+    ) {
+      return;
+    }
+    try {
+      const d = new Date();
+      const end = Math.round(d.getTime() / 1000);
+      const start = end - secondsBeforeNow[selectedRange];
+
+      setIsLoading((prev) => prev + 1);
+      setIndividualData({});
+      setAggregatedData({});
+
+      // Get aggregated metrics
+      const allPodsRes = await api.getMetrics(
+        "<token>",
+        {
+          metric: selectedMetric,
+          shouldavg: false,
+          kind: selectedService.kind,
+          name: selectedService.name,
+          namespace: selectedService.namespace,
+          startrange: start,
+          endrange: end,
+          resolution: resolutions[selectedRange],
+          pods: [],
+        },
+        {
+          id: project_id,
+          cluster_id: cluster_id,
+        }
+      );
+
+      const allPodsData: GenericMetricResponse[] = allPodsRes.data ?? [];
+      console.log(allPodsData);
+      const aggregateMetricsNormalized = new MetricNormalizer(
+        [{ results: allPodsData.flatMap((d) => d.results) }],
+        selectedMetric as AvailableMetrics
+      );
+      setAggregatedData(aggregateMetricsNormalized.getAggregatedData());
+
+      let individualMetricsNormalized: Record<
+        string,
+        NormalizedMetricsData[]
+      > = {};
+      allPodsData.map((d) => {
+        if (d.pod != null) {
+          individualMetricsNormalized[d.pod] = new MetricNormalizer(
+            [d],
+            selectedMetric as AvailableMetrics
+          ).getParsedData();
+        }
+      });
+      setIndividualData(individualMetricsNormalized);
+
+      setHpaData([]);
+      if (dataGrouping === "aggregate" && showHPA) {
+        if (selectedMetric === "cpu") {
+          await getAutoscalingThreshold(
+            "cpu_hpa_threshold",
+            true,
+            selectedService.namespace,
+            start,
+            end
+          );
+        } else if (selectedMetric === "memory") {
+          await getAutoscalingThreshold(
+            "memory_hpa_threshold",
+            true,
+            selectedService.namespace,
+            start,
+            end
+          );
+        }
+      }
+    } catch (error) {
+      console.log(JSON.stringify(error));
+    } finally {
+      setIsLoading((prev) => prev - 1);
+    }
+  };
+
+  useEffect(() => {
+    if (selectedMetric && selectedRange && selectedService) {
+      getMetrics();
+    }
+  }, [selectedMetric, selectedRange, selectedService, dataGrouping]);
+
+  const renderServiceDropdown = () => {
+    if (serviceDropdownExpanded) {
+      return (
+        <>
+          <DropdownOverlay onClick={() => setServiceDropdownExpanded(false)} />
+          <Dropdown
+            dropdownWidth="230px"
+            dropdownMaxHeight="200px"
+            onClick={() => setServiceDropdownExpanded(false)}
+          >
+            {renderServiceOptionList()}
+          </Dropdown>
+        </>
+      );
+    }
+  };
+
+  const renderServiceOptionList = () => {
+    return services.map((option: Service, i: number) => {
+      return (
+        <Option
+          key={i}
+          selected={option === selectedService}
+          onClick={() => {
+            setSelectedService(option);
+          }}
+          lastItem={i === metricsOptions.length - 1}
+        >
+          {option.name}
+        </Option>
+      );
+    });
+  };
+
+  const renderMetricDropdown = () => {
+    if (metricDropdownExpanded) {
+      return (
+        <>
+          <DropdownOverlay onClick={() => setMetricDropdownExpanded(false)} />
+          <Dropdown
+            dropdownWidth="230px"
+            dropdownMaxHeight="200px"
+            onClick={() => setMetricDropdownExpanded(false)}
+          >
+            {renderMetricOptionList()}
+          </Dropdown>
+        </>
+      );
+    }
+  };
+
+  const renderMetricOptionList = () => {
+    return metricsOptions.map((option: AvailableMetrics, i: number) => {
+      return (
+        <Option
+          key={i}
+          selected={option === selectedMetric}
+          onClick={() => {
+            setSelectedMetric(option);
+          }}
+          lastItem={i === metricsOptions.length - 1}
+        >
+          {metricsLabelMap[option].label}
+        </Option>
+      );
+    });
+  };
+
+  const firstMetricLabel =
+    metricsOptions.length > 0
+      ? metricsLabelMap[metricsOptions[0]].label
+      : "No metrics available";
+  const firstServiceLabel =
+    services.length > 0 ? services[0].name : "No services available";
+
+  let dataLegend: Record<string, number> = {};
+  if (dataGrouping === "aggregate" && Object.keys(aggregatedData).length) {
+    dataLegend = {
+      MIN: stats.min(
+        aggregatedData.min.map((d) => {
+          return d.value;
+        })
+      ),
+      AVG: stats.min(
+        aggregatedData.avg.map((d) => {
+          return d.value;
+        })
+      ),
+      MAX: stats.min(
+        aggregatedData.max.map((d) => {
+          return d.value;
+        })
+      ),
+    };
+  }
+  if (dataGrouping === "individual" && Object.keys(individualData).length) {
+    Object.entries(individualData).map(([pod, data]) => {
+      dataLegend[pod] = aggregationMap[
+        individualAggregationMethod
+      ].aggregationFunc(data.map((d) => d.value));
+    });
+  }
+
+  return (
+    <StyledMetricsSection>
+      <MetricsHeader>
+        <Flex>
+          <MetricSelector
+            onClick={() => setMetricDropdownExpanded(!metricDropdownExpanded)}
+          >
+            <MetricsLabel>
+              {selectedMetric
+                ? metricsLabelMap[selectedMetric].label
+                : firstMetricLabel}
+            </MetricsLabel>
+            <i className="material-icons">arrow_drop_down</i>
+            {renderMetricDropdown()}
+          </MetricSelector>
+          <Spacer x={1} inline />
+          <MetricSelector
+            onClick={() => setServiceDropdownExpanded(!serviceDropdownExpanded)}
+          >
+            <MetricsLabel>
+              {selectedService ? selectedService.name : firstServiceLabel}
+            </MetricsLabel>
+            <i className="material-icons">arrow_drop_down</i>
+            {renderServiceDropdown()}
+          </MetricSelector>
+
+          <Highlight color={"#7d7d81"} onClick={getMetrics}>
+            <i className="material-icons">autorenew</i>
+          </Highlight>
+        </Flex>
+        <RangeWrapper>
+          <TabSelector<DataGrouping>
+            noBuffer={true}
+            options={[
+              {
+                label: "Aggregate",
+                value: "aggregate",
+              },
+              {
+                label: "Individual",
+                value: "individual",
+              },
+            ]}
+            currentTab={dataGrouping}
+            setCurrentTab={(x) => setDataGrouping(x)}
+          />
+        </RangeWrapper>
+        {timeRangeOptions.length > 0 && (
+          <RangeWrapper>
+            <TabSelector
+              noBuffer={true}
+              options={timeRangeOptions.map((x) => ({
+                label: x,
+                value: x,
+              }))}
+              currentTab={selectedRange || timeRangeOptions[0]}
+              setCurrentTab={(x: AvailableTimeRanges) => setSelectedRange(x)}
+            />
+          </RangeWrapper>
+        )}
+      </MetricsHeader>
+      {isLoading > 0 && <Loading />}
+      {Object.keys(aggregatedData).length === 0 && isLoading === 0 && (
+        <Message>
+          No data available yet.
+          <Highlight color={"#8590ff"} onClick={getMetrics}>
+            <i className="material-icons">autorenew</i>
+            Refresh
+          </Highlight>
+        </Message>
+      )}
+      {Object.keys(aggregatedData).length > 0 &&
+        isLoading === 0 &&
+        selectedMetric &&
+        selectedRange && (
+          <>
+            {autoscaling_enabled &&
+              ["cpu", "memory"].includes(selectedMetric) && (
+                <CheckboxRow
+                  toggle={() => setShowHPA((prev: any) => !prev)}
+                  checked={showHPA}
+                  label="Show Autoscaling Threshold"
+                />
+              )}
+            <ParentSize>
+              {({ width, height }) => (
+                <AreaChart
+                  dataKey={metricsLabelMap[selectedMetric].label}
+                  aggregatedData={
+                    dataGrouping === "aggregate"
+                      ? aggregatedData
+                      : individualData
+                  }
+                  data={individualData[Object.keys(individualData)[0]]}
+                  hpaData={hpaData}
+                  hpaEnabled={
+                    showHPA && ["cpu", "memory"].includes(selectedMetric)
+                  }
+                  width={width}
+                  height={height - 10}
+                  resolution={selectedRange}
+                  margin={{ top: 40, right: -40, bottom: 0, left: 50 }}
+                />
+              )}
+            </ParentSize>
+            {Object.keys(dataLegend).length && (
+              <RowWrapper>
+                <DataLegend data={dataLegend} />
+              </RowWrapper>
+            )}
+          </>
+        )}
+    </StyledMetricsSection>
+  );
+};
+
+export default MetricsBase;

+ 71 - 0
dashboard/src/main/home/app-dashboard/expanded-app/metrics/MetricsSection.tsx

@@ -0,0 +1,71 @@
+import React, { useContext, useEffect, useState } from "react";
+import api from "shared/api";
+import { Context } from "shared/Context";
+import { ChartTypeWithExtendedConfig, StorageType } from "shared/types";
+
+import MetricsBase from "./MetricsBase";
+
+type PropsType = {
+  currentChart: ChartTypeWithExtendedConfig;
+};
+
+type Controller = {
+  metadata: {
+    name: string;
+    namespace: string;
+  };
+  kind: string;
+};
+
+const MetricsSection: React.FunctionComponent<PropsType> = ({
+  currentChart,
+}) => {
+  const [controllerOptions, setControllerOptions] = useState<Controller[]>([]);
+  const { currentCluster, currentProject } = useContext(Context);
+
+  useEffect(() => {
+    if (currentCluster == null || currentProject == null) {
+      return;
+    }
+    api
+      .getChartControllers(
+        "<token>",
+        {},
+        {
+          id: currentProject.id,
+          name: currentChart.name,
+          namespace: currentChart.namespace,
+          cluster_id: currentCluster.id,
+          revision: currentChart.version,
+        }
+      )
+      .then((res) => {
+        const controllerOptions = res.data.map((controller: Controller) => {
+          return controller;
+        });
+
+        setControllerOptions(controllerOptions);
+      })
+      .catch((err) => {
+        console.log(JSON.stringify(err));
+        setControllerOptions([]);
+      });
+  }, [currentChart, currentCluster, currentProject]);
+
+  return (
+    <MetricsBase
+      services={controllerOptions.map((c) => ({
+        name: c.metadata.name,
+        kind: c.kind,
+        namespace: c.metadata.namespace,
+      }))}
+      timeRangeOptions={["1H", "6H", "1D", "1M"]}
+      initialMetricsOptions={["cpu", "memory", "network"]}
+      autoscaling_enabled={currentChart?.config?.autoscaling?.enabled}
+      project_id={currentProject ? currentProject.id : 0}
+      cluster_id={currentCluster ? currentCluster.id : 0}
+    />
+  );
+};
+
+export default MetricsSection;

+ 192 - 0
dashboard/src/main/home/app-dashboard/expanded-app/metrics/styles.tsx

@@ -0,0 +1,192 @@
+import styled from "styled-components";
+
+export const RowWrapper = styled.div`
+  width: 100%;
+  display: flex;
+  justify-content: flex-end;
+`;
+
+export 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;
+  }
+`;
+
+export const Label = styled.div`
+  font-weight: bold;
+`;
+
+export const Relative = styled.div`
+  position: relative;
+`;
+
+export const Message = styled.div`
+  display: flex;
+  height: 100%;
+  width: calc(100% - 150px);
+  align-items: center;
+  justify-content: center;
+  margin-left: 75px;
+  text-align: center;
+  color: #ffffff44;
+  font-size: 13px;
+`;
+
+export const IconWrapper = styled.div`
+  display: flex;
+  position: relative;
+  align-items: center;
+  justify-content: center;
+  margin-top: 2px;
+  border-radius: 30px;
+  height: 25px;
+  width: 25px;
+  margin-left: 8px;
+  cursor: pointer;
+  :hover {
+    background: #ffffff22;
+  }
+`;
+
+export const SettingsIcon = styled.img`
+  opacity: 0.4;
+  width: 20px;
+  height: 20px;
+  margin-left: -1px;
+  margin-bottom: -2px;
+`;
+
+export const Flex = styled.div`
+  display: flex;
+  align-items: center;
+`;
+
+export const MetricsHeader = styled.div`
+  width: 100%;
+  display: flex;
+  align-items: center;
+  overflow: visible;
+  justify-content: space-between;
+`;
+
+export const DropdownOverlay = styled.div`
+  position: fixed;
+  width: 100%;
+  height: 100%;
+  z-index: 10;
+  left: 0px;
+  top: 0px;
+  cursor: default;
+`;
+
+export const Option = styled.div`
+  width: 100%;
+  border-top: 1px solid #00000000;
+  border-bottom: 1px solid
+    ${(props: { selected: boolean; lastItem: boolean }) =>
+    props.lastItem ? "#ffffff00" : "#ffffff15"};
+  height: 37px;
+  font-size: 13px;
+  padding-top: 9px;
+  align-items: center;
+  padding-left: 15px;
+  cursor: pointer;
+  padding-right: 10px;
+  white-space: nowrap;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  background: ${(props: { selected: boolean; lastItem: boolean }) =>
+    props.selected ? "#ffffff11" : ""};
+
+  :hover {
+    background: #ffffff22;
+  }
+`;
+
+export const Dropdown = styled.div`
+  position: absolute;
+  left: 0;
+  top: calc(100% + 10px);
+  background: #26282f;
+  width: ${(props: { dropdownWidth: string; dropdownMaxHeight: string }) =>
+    props.dropdownWidth};
+  max-height: ${(props: { dropdownWidth: string; dropdownMaxHeight: string }) =>
+    props.dropdownMaxHeight || "300px"};
+  border-radius: 3px;
+  z-index: 999;
+  overflow-y: auto;
+  margin-bottom: 20px;
+  box-shadow: 0px 4px 10px 0px #00000088;
+`;
+
+export const DropdownAlt = styled(Dropdown)`
+  padding: 20px 20px 7px;
+  overflow: visible;
+`;
+
+export const RangeWrapper = styled.div`
+  float: right;
+  font-weight: bold;
+  width: 158px;
+  margin-top: -8px;
+`;
+
+export const MetricSelector = styled.div`
+  font-size: 13px;
+  font-weight: 500;
+  position: relative;
+  color: #ffffff;
+  display: flex;
+  align-items: center;
+  cursor: pointer;
+  border-radius: 5px;
+  :hover {
+    > i {
+      background: #ffffff22;
+    }
+  }
+
+  > i {
+    border-radius: 20px;
+    font-size: 20px;
+    margin-left: 10px;
+  }
+`;
+
+export const MetricsLabel = styled.div`
+  white-space: nowrap;
+  text-overflow: ellipsis;
+  overflow: hidden;
+  max-width: 200px;
+`;
+
+export const StyledMetricsSection = styled.div`
+  width: 100%;
+  min-height: 480px;
+  height: calc(100vh - 350px);
+  display: flex;
+  flex-direction: column;
+  position: relative;
+  font-size: 13px;
+  animation: floatIn 0.3s;
+  animation-timing-function: ease-out;
+  animation-fill-mode: forwards;
+  @keyframes floatIn {
+    from {
+      opacity: 0;
+      transform: translateY(10px);
+    }
+    to {
+      opacity: 1;
+      transform: translateY(0px);
+    }
+  }
+`;

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

@@ -0,0 +1,121 @@
+import * as stats from "simple-statistics";
+
+export type MetricsCPUDataResponse = {
+  pod?: string;
+  results: {
+    date: number;
+    cpu: string;
+  }[];
+};
+
+export type MetricsMemoryDataResponse = {
+  pod?: string;
+  results: {
+    date: number;
+    memory: string;
+  }[];
+};
+
+export type MetricsNetworkDataResponse = {
+  pod?: string;
+  results: {
+    date: number;
+    bytes: string;
+  }[];
+};
+
+export type MetricsNGINXErrorsDataResponse = {
+  pod?: string;
+  results: {
+    date: number;
+    error_pct: string;
+  }[];
+};
+
+export type MetricsNGINXLatencyDataResponse = {
+  pod?: string;
+  results: {
+    date: number;
+    latency: string;
+  }[];
+};
+
+export type MetricsHpaReplicasDataResponse = {
+  pod?: string;
+  results: {
+    date: number;
+    replicas: string;
+  }[];
+};
+
+export type GenericMetricResponse = {
+  pod?: string;
+  results: {
+    date: number;
+    cpu: string;
+    memory: string;
+    bytes: string;
+    error_pct: string;
+    replicas: string;
+    latency: string;
+  }[];
+};
+
+export type NormalizedMetricsData = {
+  date: number; // unix timestamp
+  value: number; // value
+};
+
+export type DataGrouping = "aggregate" | "individual";
+
+export type AggregationMethod = "min" | "avg" | "max";
+
+export type AggregationDetails = {
+  label: string;
+  aggregationFunc: (data: number[]) => number;
+};
+
+export type AvailableMetrics =
+  | "cpu"
+  | "memory"
+  | "network"
+  | "nginx:errors"
+  | "nginx:latency"
+  | "nginx:latency-histogram"
+  | "cpu_hpa_threshold"
+  | "memory_hpa_threshold"
+  | "hpa_replicas";
+
+export type MetricOptions = {
+  label: string;
+  enable_percentiles: boolean;
+};
+
+export const MetricOptions = {
+  default: (label: string, enable_percentiles?: boolean): MetricOptions => ({
+    label: label,
+    enable_percentiles: enable_percentiles ?? false,
+  }),
+};
+
+export type Service = {
+  name: string;
+  kind: string;
+  namespace: string;
+};
+
+export type AvailableTimeRanges = "1H" | "6H" | "1D" | "1M";
+
+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,
+};

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

@@ -0,0 +1,57 @@
+type RGB = {
+  r: number;
+  g: number;
+  b: number;
+};
+
+export function pickColor(
+  color1: string,
+  color2: string,
+  index: number,
+  total: number
+) {
+  if (total == 1) {
+    return color1;
+  }
+
+  const w1 = index / (total - 1);
+  const w2 = 1 - w1;
+
+  const rgb1 = hexToRgb(color1);
+  const rgb2 = hexToRgb(color2);
+
+  if (rgb1 == null || rgb2 == null) {
+    return "#000000";
+  }
+
+  const rgb: RGB = {
+    r: Math.round(rgb1.r * w1 + rgb2.r * w2),
+    g: Math.round(rgb1.g * w1 + rgb2.g * w2),
+    b: Math.round(rgb1.b * w1 + rgb2.b * w2),
+  };
+
+  return rgbToHex(rgb);
+}
+
+function componentToHex(c: number) {
+  c = Math.round(c);
+  var hex = c.toString(16);
+  return hex.length == 1 ? "0" + hex : hex;
+}
+
+function rgbToHex(rgb: RGB) {
+  return (
+    "#" + componentToHex(rgb.r) + componentToHex(rgb.g) + componentToHex(rgb.b)
+  );
+}
+
+function hexToRgb(hex: string): RGB | null {
+  const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex);
+  return result
+    ? {
+        r: parseInt(result[1], 16),
+        g: parseInt(result[2], 16),
+        b: parseInt(result[3], 16),
+      }
+    : null;
+}

+ 3 - 3
dashboard/src/main/home/cluster-dashboard/dashboard/Metrics.tsx

@@ -209,7 +209,7 @@ const Metrics: React.FC = () => {
 
   const getMetrics = async () => {
     try {
-      let shouldsum = true;
+      let shouldavg = true;
       let namespace = "default";
 
       // calculate start and end range
@@ -229,7 +229,7 @@ const Metrics: React.FC = () => {
         "<token>",
         {
           metric: selectedMetric,
-          shouldsum: false,
+          shouldavg: false,
           kind: "Ingress",
           namespace: selectedIngress?.namespace || "default",
           percentile:
@@ -261,7 +261,7 @@ const Metrics: React.FC = () => {
         "<token>",
         {
           metric: selectedMetric,
-          shouldsum: false,
+          shouldavg: false,
           kind: "Ingress",
           namespace: selectedIngress?.namespace || "default",
           percentile:

+ 1 - 2
dashboard/src/main/home/cluster-dashboard/expanded-chart/logs-section/useAgentLogs.ts

@@ -45,8 +45,7 @@ export const parseLogs = (logs: string[] = []): Log[] => {
 
       const parsedLine: LogLine = JSON.parse(logLine);
 
-      LogSchema.parse(parsedLine);
-
+      x;
       // TODO Move log parsing to the render method
       const ansiLog = Anser.ansiToJson(parsedLine.log);
       return {

+ 3 - 195
dashboard/src/main/home/cluster-dashboard/expanded-chart/metrics/JobMetricsSection.tsx

@@ -20,28 +20,10 @@ type PropsType = {
   jobRun: any;
 };
 
-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 JobMetricsSection: React.FunctionComponent<PropsType> = ({
   jobChart: currentChart,
   jobRun,
 }) => {
-  const [controllerOptions, setControllerOptions] = useState([]);
-  const [selectedController, setSelectedController] = useState(null);
-  const [ingressOptions, setIngressOptions] = useState([]);
-  const [selectedIngress, setSelectedIngress] = useState(null);
   const [selectedRange, setSelectedRange] = useState("1H");
   const [selectedMetric, setSelectedMetric] = useState("cpu");
   const [selectedMetricLabel, setSelectedMetricLabel] = useState(
@@ -49,54 +31,16 @@ const JobMetricsSection: React.FunctionComponent<PropsType> = ({
   );
   const [dropdownExpanded, setDropdownExpanded] = useState(false);
   const [data, setData] = useState<NormalizedMetricsData[]>([]);
-  const [showMetricsSettings, setShowMetricsSettings] = useState(false);
   const [metricsOptions, setMetricsOptions] = useState([
     { value: "cpu", label: "CPU Utilization (vCPUs)" },
     { value: "memory", label: "RAM Utilization (Mi)" },
   ]);
   const [isLoading, setIsLoading] = useState(0);
-  const [hpaData, setHpaData] = useState([]);
-  const [hpaEnabled, setHpaEnabled] = useState(
-    currentChart?.config?.autoscaling?.enabled
-  );
 
   const { currentCluster, currentProject, setCurrentError } = useContext(
     Context
   );
 
-  useEffect(() => {
-    setIsLoading((prev) => prev + 1);
-
-    api
-      .getChartControllers(
-        "<token>",
-        {},
-        {
-          id: currentProject.id,
-          name: currentChart.name,
-          namespace: currentChart.namespace,
-          cluster_id: currentCluster.id,
-          revision: currentChart.version,
-        }
-      )
-      .then((res) => {
-        const controllerOptions = res.data.map((controller: any) => {
-          let name = controller?.metadata?.name;
-          return { value: controller, label: name };
-        });
-
-        setControllerOptions(controllerOptions);
-        setSelectedController(controllerOptions[0]?.value);
-      })
-      .catch((err) => {
-        setCurrentError(JSON.stringify(err));
-        setControllerOptions([]);
-      })
-      .finally(() => {
-        setIsLoading((prev) => prev - 1);
-      });
-  }, [currentChart, currentCluster, currentProject]);
-
   // prometheus has a limit of 11,000 data points to return per metric. we thus ensure that
   // the resolution will not exceed 11,000 data points.
   //
@@ -114,48 +58,6 @@ const JobMetricsSection: React.FunctionComponent<PropsType> = ({
     return "5h";
   };
 
-  const getAutoscalingThreshold = async (
-    metricType: "cpu_hpa_threshold" | "memory_hpa_threshold",
-    shouldsum: boolean,
-    namespace: string,
-    start: number,
-    end: number
-  ) => {
-    setIsLoading((prev) => prev + 1);
-    setHpaData([]);
-    try {
-      const res = await api.getMetrics(
-        "<token>",
-        {
-          metric: metricType,
-          shouldsum: shouldsum,
-          kind: selectedController?.kind,
-          name: selectedController?.metadata.name,
-          namespace: namespace,
-          startrange: start,
-          endrange: end,
-          resolution: getJobResolution(start, end),
-          pods: [],
-        },
-        {
-          id: currentProject.id,
-          cluster_id: currentCluster.id,
-        }
-      );
-
-      if (!Array.isArray(res.data) || !res.data[0]?.results) {
-        return;
-      }
-      const autoscalingMetrics = new MetricNormalizer(res.data, metricType);
-      setHpaData(autoscalingMetrics.getParsedData());
-      return;
-    } catch (error) {
-      console.error(error);
-    } finally {
-      setIsLoading((prev) => prev - 1);
-    }
-  };
-
   const getMetrics = async () => {
     try {
       let namespace = currentChart.namespace;
@@ -179,7 +81,7 @@ const JobMetricsSection: React.FunctionComponent<PropsType> = ({
         "<token>",
         {
           metric: selectedMetric,
-          shouldsum: true,
+          shouldavg: true,
           kind: "job",
           name: jobRun?.metadata?.name,
           namespace: namespace,
@@ -211,48 +113,10 @@ const JobMetricsSection: React.FunctionComponent<PropsType> = ({
   };
 
   useEffect(() => {
-    if (selectedMetric && selectedRange && selectedController) {
+    if (selectedMetric && selectedRange) {
       getMetrics();
     }
-  }, [selectedMetric, selectedRange, selectedController, selectedIngress]);
-
-  const renderMetricsSettings = () => {
-    if (showMetricsSettings && true) {
-      if (selectedMetric == "nginx:errors") {
-        return (
-          <>
-            <DropdownOverlay onClick={() => setShowMetricsSettings(false)} />
-            <DropdownAlt dropdownWidth="330px" dropdownMaxHeight="300px">
-              <Label>Additional Settings</Label>
-              <SelectRow
-                label="Target Ingress"
-                value={selectedIngress}
-                setActiveValue={(x: any) => setSelectedIngress(x)}
-                options={ingressOptions}
-                width="100%"
-              />
-            </DropdownAlt>
-          </>
-        );
-      }
-
-      return (
-        <>
-          <DropdownOverlay onClick={() => setShowMetricsSettings(false)} />
-          <DropdownAlt dropdownWidth="330px" dropdownMaxHeight="300px">
-            <Label>Additional Settings</Label>
-            <SelectRow
-              label="Target Controller"
-              value={selectedController}
-              setActiveValue={(x: any) => setSelectedController(x)}
-              options={controllerOptions}
-              width="100%"
-            />
-          </DropdownAlt>
-        </>
-      );
-    }
-  };
+  }, [selectedMetric, selectedRange]);
 
   const renderDropdown = () => {
     if (dropdownExpanded) {
@@ -340,23 +204,11 @@ const JobMetricsSection: React.FunctionComponent<PropsType> = ({
       )}
       {data.length > 0 && isLoading === 0 && (
         <>
-          {currentChart?.config?.autoscaling?.enabled &&
-            ["cpu", "memory"].includes(selectedMetric) && (
-              <CheckboxRow
-                toggle={() => setHpaEnabled((prev: any) => !prev)}
-                checked={hpaEnabled}
-                label="Show Autoscaling Threshold"
-              />
-            )}
           <ParentSize>
             {({ width, height }) => (
               <AreaChart
                 dataKey={selectedMetricLabel}
                 data={data}
-                hpaData={hpaData}
-                hpaEnabled={
-                  hpaEnabled && ["cpu", "memory"].includes(selectedMetric)
-                }
                 width={width}
                 height={height - 10}
                 resolution={selectedRange}
@@ -386,14 +238,6 @@ const Highlight = styled.div`
   }
 `;
 
-const Label = styled.div`
-  font-weight: bold;
-`;
-
-const Relative = styled.div`
-  position: relative;
-`;
-
 const Message = styled.div`
   display: flex;
   height: 100%;
@@ -406,30 +250,6 @@ const Message = styled.div`
   font-size: 13px;
 `;
 
-const IconWrapper = styled.div`
-  display: flex;
-  position: relative;
-  align-items: center;
-  justify-content: center;
-  margin-top: 2px;
-  border-radius: 30px;
-  height: 25px;
-  width: 25px;
-  margin-left: 8px;
-  cursor: pointer;
-  :hover {
-    background: #ffffff22;
-  }
-`;
-
-const SettingsIcon = styled.img`
-  opacity: 0.4;
-  width: 20px;
-  height: 20px;
-  margin-left: -1px;
-  margin-bottom: -2px;
-`;
-
 const Flex = styled.div`
   display: flex;
   align-items: center;
@@ -493,18 +313,6 @@ const Dropdown = styled.div`
   box-shadow: 0px 4px 10px 0px #00000088;
 `;
 
-const DropdownAlt = styled(Dropdown)`
-  padding: 20px 20px 7px;
-  overflow: visible;
-`;
-
-const RangeWrapper = styled.div`
-  float: right;
-  font-weight: bold;
-  width: 156px;
-  margin-top: -8px;
-`;
-
 const MetricSelector = styled.div`
   font-size: 13px;
   font-weight: 500;

+ 10 - 10
dashboard/src/main/home/cluster-dashboard/expanded-chart/metrics/MetricsSection.tsx

@@ -232,7 +232,7 @@ const MetricsSection: React.FunctionComponent<PropsType> = ({
 
   const getAutoscalingThreshold = async (
     metricType: "cpu_hpa_threshold" | "memory_hpa_threshold",
-    shouldsum: boolean,
+    shouldavg: boolean,
     namespace: string,
     start: number,
     end: number
@@ -244,7 +244,7 @@ const MetricsSection: React.FunctionComponent<PropsType> = ({
         "<token>",
         {
           metric: metricType,
-          shouldsum: shouldsum,
+          shouldavg: shouldavg,
           kind: selectedController?.kind,
           name: selectedController?.metadata.name,
           namespace: namespace,
@@ -277,7 +277,7 @@ const MetricsSection: React.FunctionComponent<PropsType> = ({
       return;
     }
     try {
-      let shouldsum = selectedPod === "All";
+      let shouldavg = selectedPod === "All";
       let namespace = currentChart.namespace;
 
       // calculate start and end range
@@ -287,14 +287,14 @@ const MetricsSection: React.FunctionComponent<PropsType> = ({
 
       let podNames = [] as string[];
 
-      if (!shouldsum) {
+      if (!shouldavg) {
         podNames = [selectedPod];
       }
 
       if (selectedMetric == "nginx:errors") {
         podNames = [selectedIngress?.name];
         namespace = selectedIngress?.namespace || "default";
-        shouldsum = false;
+        shouldavg = false;
       }
 
       setIsLoading((prev) => prev + 1);
@@ -306,7 +306,7 @@ const MetricsSection: React.FunctionComponent<PropsType> = ({
         "<token>",
         {
           metric: selectedMetric,
-          shouldsum: false,
+          shouldavg: false,
           kind: selectedController?.kind,
           name: selectedController?.metadata.name,
           namespace: namespace,
@@ -334,7 +334,7 @@ const MetricsSection: React.FunctionComponent<PropsType> = ({
         "<token>",
         {
           metric: selectedMetric,
-          shouldsum: shouldsum,
+          shouldavg: shouldavg,
           kind: selectedController?.kind,
           name: selectedController?.metadata.name,
           namespace: namespace,
@@ -351,11 +351,11 @@ const MetricsSection: React.FunctionComponent<PropsType> = ({
 
       setHpaData([]);
       const isHpaEnabled = currentChart?.config?.autoscaling?.enabled;
-      if (shouldsum && isHpaEnabled) {
+      if (shouldavg && isHpaEnabled) {
         if (selectedMetric === "cpu") {
           await getAutoscalingThreshold(
             "cpu_hpa_threshold",
-            shouldsum,
+            shouldavg,
             namespace,
             start,
             end
@@ -363,7 +363,7 @@ const MetricsSection: React.FunctionComponent<PropsType> = ({
         } else if (selectedMetric === "memory") {
           await getAutoscalingThreshold(
             "memory_hpa_threshold",
-            shouldsum,
+            shouldavg,
             namespace,
             start,
             end

+ 1 - 1
dashboard/src/shared/api.tsx

@@ -1284,7 +1284,7 @@ const getAllReleasePods = baseApi<
 const getMetrics = baseApi<
   {
     metric: string;
-    shouldsum: boolean;
+    shouldavg: boolean;
     pods?: string[];
     kind?: string; // the controller kind
     name?: string;

+ 3 - 3
internal/kubernetes/prometheus/metrics.go

@@ -109,7 +109,7 @@ func GetIngressesWithNGINXAnnotation(clientset kubernetes.Interface) ([]SimpleIn
 
 type QueryOpts struct {
 	Metric     string   `schema:"metric"`
-	ShouldSum  bool     `schema:"shouldsum"`
+	ShouldAvg  bool     `schema:"shouldavg"`
 	Kind       string   `schema:"kind"`
 	PodList    []string `schema:"pods"`
 	Name       string   `schema:"name"`
@@ -196,8 +196,8 @@ func QueryPrometheus(
 		query = createHPACurrentReplicasQuery(metricName, opts.Name, opts.Namespace, appLabel, hpaMetricName)
 	}
 
-	if opts.ShouldSum {
-		query = fmt.Sprintf("sum(%s)", query)
+	if opts.ShouldAvg {
+		query = fmt.Sprintf("avg(%s)", query)
 	}
 
 	queryParams := map[string]string{