فهرست منبع

Single page metrics with nginx status metrics (#3415)

Co-authored-by: Feroze Mohideen <feroze@porter.run>
jose-fully-ported 2 سال پیش
والد
کامیت
45be9d4877

تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 523 - 248
dashboard/package-lock.json


+ 14 - 10
dashboard/package.json

@@ -8,20 +8,24 @@
     "@material-ui/core": "^4.11.3",
     "@material-ui/lab": "^4.0.0-alpha.61",
     "@porter-dev/api-contracts": "^0.0.86",
+    "@react-spring/web": "^9.6.1",
     "@sentry/react": "^6.13.2",
     "@sentry/tracing": "^6.13.2",
     "@tanstack/react-query": "^4.13.0",
     "@tanstack/react-query-devtools": "^4.13.5",
-    "@visx/axis": "^1.6.1",
-    "@visx/curve": "^1.0.0",
-    "@visx/event": "^1.3.0",
-    "@visx/gradient": "^1.0.0",
-    "@visx/grid": "^1.4.0",
-    "@visx/mock-data": "^1.0.0",
-    "@visx/responsive": "^1.3.0",
-    "@visx/scale": "^1.4.0",
-    "@visx/shape": "^1.4.0",
-    "@visx/tooltip": "^1.3.0",
+    "@visx/axis": "^3.3.0",
+    "@visx/curve": "^3.3.0",
+    "@visx/event": "^3.3.0",
+    "@visx/gradient": "^3.3.0",
+    "@visx/grid": "^3.3.0",
+    "@visx/mock-data": "^3.3.0",
+    "@visx/pattern": "^3.3.0",
+    "@visx/react-spring": "^3.3.0",
+    "@visx/responsive": "^3.3.0",
+    "@visx/scale": "^3.3.0",
+    "@visx/shape": "^3.3.0",
+    "@visx/tooltip": "^3.3.0",
+    "@visx/xychart": "^3.3.0",
     "ace-builds": "^1.16.0",
     "anser": "^2.0.1",
     "axios": "^0.21.2",

+ 9 - 5
dashboard/src/components/form-components/SelectRow.tsx

@@ -8,6 +8,7 @@ type PropsType<T> = {
   value: T;
   setActiveValue: (x: T) => void;
   options: { value: T; label: string }[];
+  displayFlex?: boolean;
   dropdownLabel?: string;
   width?: string;
   dropdownMaxHeight?: string;
@@ -19,9 +20,9 @@ type PropsType<T> = {
 
 export default function SelectRow<T>(props: PropsType<T>) {
   return (
-    <StyledSelectRow>
+    <StyledSelectRow displayFlex={props.displayFlex}>
       <Wrapper>
-        <Label>{props.label}</Label>
+        <Label displayFlex={props.displayFlex}>{props.label}</Label>
         {props.doc ? (
           <a href={props.doc} target="_blank">
             <i className="material-icons">help_outline</i>
@@ -65,13 +66,16 @@ const Wrapper = styled.div`
   }
 `;
 
-const Label = styled.div`
+const Label = styled.div<{ displayFlex?: boolean }>`
   color: #ffffff;
-  margin-bottom: 10px;
   font-size: 13px;
+  margin-bottom: 10px;
+  margin-top: ${props => props.displayFlex ? "10px" : 0};
+  margin-right: ${props => props.displayFlex ? "10px" : 0};
 `;
 
-const StyledSelectRow = styled.div`
+const StyledSelectRow = styled.div<{ displayFlex?: boolean }>`
+  display: ${props => props.displayFlex ? "flex" : "block"};
   margin-bottom: 15px;
   margin-top: 20px;
 `;

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

@@ -38,7 +38,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";
@@ -692,7 +692,7 @@ const ExpandedApp: React.FC<Props> = ({ ...props }) => {
           filterOpts={queryParamOpts}
         />;
       case "metrics":
-        return <MetricsSection currentChart={appData.chart} />;
+        return <MetricsSection currentChart={appData.chart} appName={appData.app.name} serviceName={queryParamOpts.service} />;
       case "debug":
         return <StatusSectionFC currentChart={appData.chart} />;
       case "environment":

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

@@ -1,769 +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 "./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 [isAggregated, setIsAggregated] = useState<boolean>(false);
-  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<NormalizedMetricsData[]>([]);
-  const [hpaEnabled, setHpaEnabled] = useState(false);
-  const [showHpaToggle, setShowHpaToggle] = useState(false);
-
-  const { currentCluster, currentProject, setCurrentError } = useContext(
-    Context
-  );
-
-  // Add or remove hpa replicas chart option when current chart is updated
-  useEffect(() => {
-    const serviceName: string = selectedController?.metadata.labels["app.kubernetes.io/name"];
-    const isHpaEnabled: boolean = currentChart?.config?.[serviceName]?.autoscaling?.enabled;
-    if (isHpaEnabled) {
-      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];
-      });
-    }
-  }, [selectedController, 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({});
-      setIsAggregated(shouldsum)
-
-      // 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,
-      );
-      const allPodsAggregatedData = allPodsMetricsNormalized.getAggregatedData()
-      let data: NormalizedMetricsData[] = []
-      if (shouldsum) {
-        setData(allPodsAggregatedData["avg"])
-        delete allPodsAggregatedData["avg"]
-      }
-      setAggregatedData(allPodsAggregatedData);
-
-
-      setHpaData([]);
-      const serviceName: string = selectedController?.metadata.labels["app.kubernetes.io/name"];
-      const isHpaEnabled: boolean = currentChart?.config?.[serviceName]?.autoscaling?.enabled;
-      setShowHpaToggle(isHpaEnabled);
-      setHpaEnabled(isHpaEnabled);
-      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
-          );
-        }
-      }
-
-      if (!shouldsum) {
-        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,
-          }
-        );
-
-        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 && (
-        <>
-          {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>
-        </>
-      )}
-    </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);
-    }
-  }
-`;

+ 1 - 1
dashboard/src/main/home/app-dashboard/expanded-app/activity-feed/events/cards/ServiceStatusDetail.tsx

@@ -61,7 +61,7 @@ const ServiceStatusDetail: React.FC<Props> = ({
                                         </Link>
                                         <Spacer inline x={0.5} />
                                         <Link
-                                            to={`/apps/${appName}/logs?version=${revision}&service=${key}`}
+                                            to={`/apps/${appName}/metrics?service=${key}`}
                                             hasunderline
                                             hoverColor="#949eff"
                                         >

+ 1 - 3
dashboard/src/main/home/app-dashboard/expanded-app/metrics/AreaChart.tsx

@@ -1,7 +1,5 @@
 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";
@@ -18,7 +16,6 @@ import { bisector, extent, max } from "d3-array";
 import { timeFormat } from "d3-time-format";
 import { NormalizedMetricsData } from "../../../cluster-dashboard/expanded-chart/metrics/types";
 import { AggregatedDataColors } from "../../../cluster-dashboard/expanded-chart/metrics/utils";
-import { pickColor } from "./utils";
 
 var globalData: NormalizedMetricsData[];
 
@@ -237,6 +234,7 @@ const AreaChart: React.FunctionComponent<AreaProps> = ({
     if (width == 0 || height == 0 || width < 10) {
         return null;
     }
+
     const hpaGraphTooltipGlyphPosition =
         (hpaEnabled &&
             tooltipData?.tooltipHpaData &&

+ 494 - 0
dashboard/src/main/home/app-dashboard/expanded-app/metrics/MetricsChart.tsx

@@ -0,0 +1,494 @@
+import React, { useContext, useEffect, 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,
+};
+
+type PropsType = {
+    currentChart: ChartTypeWithExtendedConfig;
+    pods: any[];
+    selectedController: any;
+    selectedIngress: any;
+    selectedMetric: string;
+    selectedMetricLabel: string;
+    selectedPod: string;
+    selectedRange: string;
+};
+
+const MetricsChart: React.FunctionComponent<PropsType> = ({
+    currentChart,
+    pods,
+    selectedController,
+    selectedIngress,
+    selectedMetric,
+    selectedMetricLabel,
+    selectedPod,
+    selectedRange,
+}) => {
+    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();
+        }
+    };
+
+    const getAppMetrics = async () => {
+        if (pods?.length == 0) {
+            return;
+        }
+
+        if (selectedMetric == "nginx:status") {
+            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;
+            }
+
+            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,
+                }
+            );
+
+            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);
+        }
+    }
+
+    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>
+                    </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>
+                </>
+            )}
+        </StyledMetricsChart>
+    );
+};
+
+export default MetricsChart;
+
+const Flex = styled.div`
+  display: flex;
+  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%;
+  width: calc(100% - 150px);
+  align-items: center;
+  justify-content: center;
+  margin-left: 75px;
+  text-align: center;
+  color: #ffffff44;
+  font-size: 13px;
+`;
+
+const MetricsHeader = styled.div`
+  width: 100%;
+  display: flex;
+  align-items: center;
+  overflow: visible;
+  justify-content: space-between;
+`;
+
+const MetricsLabel = styled.div`
+  white-space: nowrap;
+  text-overflow: ellipsis;
+  overflow: hidden;
+  max-width: 200px;
+`;
+
+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 RowWrapper = styled.div`
+  width: 100%;
+  display: flex;
+  justify-content: flex-end;
+`;
+
+
+const StyledMetricsChart = styled.div`
+  width: 100%;
+  min-height: 240px;
+  height: calc(100vh - 700px);
+  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);
+    }
+  }
+`;

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

@@ -0,0 +1,318 @@
+import React, { useContext, useEffect, useState } from "react";
+import styled from "styled-components";
+
+import api from "shared/api";
+import { Context } from "shared/Context";
+import { ChartTypeWithExtendedConfig } from "shared/types";
+
+import TabSelector from "components/TabSelector";
+import SelectRow from "components/form-components/SelectRow";
+import MetricsChart from "./MetricsChart";
+import { getServiceNameFromControllerName } from "./utils";
+type PropsType = {
+  currentChart: ChartTypeWithExtendedConfig;
+  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 [selectedRange, setSelectedRange] = useState("1H");
+  const [selectedMetric, setSelectedMetric] = useState("cpu");
+  const [isLoading, setIsLoading] = useState(0);
+
+  const { currentCluster, currentProject, setCurrentError } = 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(
+        "<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) => {
+          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);
+      });
+  }, [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);
+      })
+      .catch((err) => {
+        setCurrentError(JSON.stringify(err));
+        return;
+      })
+      .finally(() => {
+        setIsLoading((prev) => prev - 1);
+      });
+  };
+
+  const renderHpaChart = () => {
+    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}
+      />
+    ) : 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%"
+            />
+          }
+        </Flex>
+        <RangeWrapper>
+          <Relative>
+          </Relative>
+          <TabSelector
+            noBuffer={true}
+            options={[
+              { value: "1H", label: "1H" },
+              { value: "6H", label: "6H" },
+              { value: "1D", label: "1D" },
+              { value: "1M", label: "1M" },
+            ]}
+            currentTab={selectedRange}
+            setCurrentTab={(x: string) => setSelectedRange(x)}
+          />
+        </RangeWrapper>
+      </MetricsHeader>
+      <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="Nginx Status Codes"
+        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}
+        />
+      )}
+    </StyledMetricsSection>
+  );
+};
+
+export default MetricsSection;
+
+const Relative = styled.div`
+  position: relative;
+`;
+
+const Flex = styled.div`
+  display: flex;
+  align-items: center;
+`;
+
+const MetricsHeader = styled.div`
+  width: 100%;
+  display: flex;
+  align-items: center;
+  overflow: visible;
+  justify-content: space-between;
+`;
+
+const RangeWrapper = styled.div`
+  float: right;
+  font-weight: bold;
+  width: 158px;
+  margin-top: -8px;
+`;
+
+const StyledMetricsSection = styled.div`
+  width: 100%;
+  display: flex;
+  flex-direction: column;
+  position: relative;
+`;

+ 285 - 0
dashboard/src/main/home/app-dashboard/expanded-app/metrics/StackedAreaChart.tsx

@@ -0,0 +1,285 @@
+import _ from "lodash";
+import React, { useMemo, useRef } from "react";
+import styled from "styled-components";
+import { AreaSeries, AreaStack, Tooltip, XYChart } from "@visx/xychart";
+import { AxisBottom, AxisLeft } from "@visx/axis"; 
+import { curveMonotoneX as visxCurve } from "@visx/curve";
+import { GridColumns, GridRows } from "@visx/grid";
+import { scaleLinear, scaleTime } from "@visx/scale";
+import { bisector, extent, max } from "d3-array";
+import { timeFormat } from "d3-time-format";
+
+import { default as areaTheme } from "./themes/area";
+import { NormalizedNginxStatusMetricsData } from "../../../cluster-dashboard/expanded-chart/metrics/types";
+
+var globalData: NormalizedNginxStatusMetricsData[];
+
+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");
+
+const dateScaleConfig = { type: 'point' } as const;
+const yScaleConfig = { type: 'linear' } as const;
+
+// map resolutions to formats
+const formats: { [range: string]: (date: Date) => string } = {
+    "1H": hourFormat,
+    "6H": hourFormat,
+    "1D": hourFormat,
+    "1M": dayFormat,
+};
+
+type StatusCode = "1xx" | "2xx" | "3xx" | "4xx" | "5xx";
+
+// accessors
+const getDate = (d: NormalizedNginxStatusMetricsData) => new Date(d.date * 1000);
+const getDateAsString = (d: NormalizedNginxStatusMetricsData) => formatDate(getDate(d));
+const getStatusValue = (d: NormalizedNginxStatusMetricsData, level: string) =>{
+    const statusLevel = level as keyof NormalizedNginxStatusMetricsData;
+    return d?[statusLevel] && Number(d[statusLevel]?.toFixed(4)) : 0;
+}
+const get1xxValue = (d: NormalizedNginxStatusMetricsData) => getStatusValue(d, "1xx");
+const get2xxValue = (d: NormalizedNginxStatusMetricsData) => getStatusValue(d, "2xx");
+const get3xxValue = (d: NormalizedNginxStatusMetricsData) => getStatusValue(d, "3xx");
+const get4xxValue = (d: NormalizedNginxStatusMetricsData) => getStatusValue(d, "4xx");
+const get5xxValue = (d: NormalizedNginxStatusMetricsData) => getStatusValue(d, "5xx");
+
+const getMaxValue = (d: NormalizedNginxStatusMetricsData) =>
+    max([get1xxValue(d), get2xxValue(d), get3xxValue(d), get4xxValue(d), get5xxValue(d)]) || 0;
+
+const bisectDate = bisector<NormalizedNginxStatusMetricsData, Date>(
+    (d) => new Date(d.date * 1000)
+).left;
+
+export type StackedAreaChartProps = {
+    data: NormalizedNginxStatusMetricsData[];
+    dataKey: string;
+    resolution: string;
+    width: number;
+    height: number;
+    margin?: { top: number; right: number; bottom: number; left: number };
+};
+
+const StackedAreaChart: React.FunctionComponent<StackedAreaChartProps> = ({
+    data,
+    dataKey,
+    resolution,
+    width,
+    height,
+    margin = { top: 0, right: 0, bottom: 0, left: 0 },
+}) => {
+    globalData = data;
+
+    const svgContainer = useRef();
+    // bounds
+    const innerWidth = width - margin.left - margin.right - 40;
+    const innerHeight = height - margin.top - margin.bottom - 20;
+
+    // scales
+    const dateScale = useMemo(
+        () =>
+            scaleTime({
+                range: [margin.left, innerWidth + margin.left],
+                domain: extent(
+                    globalData,
+                    getDate
+                ) as [Date, Date],
+            }),
+        [margin.left, width, height, data]
+    );
+
+    const valueScale = useMemo(
+        () =>
+            scaleLinear({
+                range: [innerHeight + margin.top, margin.top],
+                domain: [
+                    0,
+                    1.25 *
+                    max(
+                        globalData,
+                        getMaxValue
+                    ),
+                ],
+                nice: true,
+            }),
+        [margin.top, width, height, data]
+    );
+
+    if (width == 0 || height == 0 || width < 10) {
+        return null;
+    }
+
+    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}
+                />
+
+                <XYChart
+                    theme={areaTheme}
+                    xScale={dateScaleConfig}
+                    yScale={yScaleConfig}
+                    height={height}
+                    width={width}
+                    margin={{ top: 0, right: 0, bottom: 20, left: 50 }}
+
+                >
+                    <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"
+                    />
+                    <AreaStack curve={visxCurve}>
+                        <AreaSeries
+                            dataKey="1xx"
+                            data={data}
+                            xAccessor={getDate}
+                            yAccessor={get1xxValue}
+                            fillOpacity={0.4}
+                        />
+                        <AreaSeries
+                            dataKey="2xx"
+                            data={data}
+                            xAccessor={getDate}
+                            yAccessor={get2xxValue}
+                            fillOpacity={0.4}
+                        />
+                        <AreaSeries
+                            dataKey="3xx"
+                            data={data}
+                            xAccessor={getDate}
+                            yAccessor={get3xxValue}
+                            fillOpacity={0.4}
+                        />
+                        <AreaSeries
+                            dataKey="4xx"
+                            data={data}
+                            xAccessor={getDate}
+                            yAccessor={get4xxValue}
+                            fillOpacity={0.4}
+                        />
+                        <AreaSeries
+                            dataKey="5xx"
+                            data={data}
+                            xAccessor={getDate}
+                            yAccessor={get5xxValue}
+                            fillOpacity={0.4}
+                        />
+                    </AreaStack>
+                    <AxisLeft
+                        left={10}
+                        scale={valueScale}
+                        hideAxisLine={true}
+                        hideTicks={true}
+                        tickLabelProps={() => ({
+                            fill: "white",
+                            fontSize: 11,
+                            textAnchor: "start",
+                            fillOpacity: 0.4,
+                            dy: 0,
+                        })}
+                    />
+                    <Tooltip<NormalizedMetricsData>
+                        showHorizontalCrosshair={false}
+                        showVerticalCrosshair={true}
+                        snapTooltipToDatumX={true}
+                        snapTooltipToDatumY={true}
+                        showDatumGlyph={true}
+                        applyPositionStyle={true}
+                        style={{ 
+                            background: 'rgb(38, 39, 47)',
+                            borderRadius: '3px',
+                            boxShadow: 'rgba(33, 33, 33, 0.2) 0px 1px 2px',
+                            color: 'rgb(170, 170, 187)',
+                            fontSize: '14px',
+                            lineHeight: '1em',
+                            padding: '0.3rem 0.5rem',
+                            pointerEvents: 'none',
+                        }}
+                        renderTooltip={({ tooltipData, colorScale }) => (
+                            <>
+                                {/** date */}
+                                <TooltipDate>{(tooltipData?.nearestDatum?.datum &&
+                                    getDateAsString(tooltipData?.nearestDatum?.datum)) ||
+                                    "No date"}</TooltipDate>
+                                
+                                {/** temperatures */}
+                                {((Object.keys(tooltipData?.datumByKey ?? {})).filter((city) => city) as StatusCode[]).map((statusCode) => {
+                                    const rps =
+                                        tooltipData?.nearestDatum?.datum &&
+                                        getStatusValue(
+                                            tooltipData?.nearestDatum?.datum, statusCode
+                                        );
+
+                                    return (
+                                        <TooltipDataRow key={statusCode} color={colorScale?.(statusCode)} style={{
+                                                    textDecoration:
+                                                        tooltipData?.nearestDatum?.key === statusCode
+                                                            ? "underline"
+                                                            : undefined
+                                                }}
+                                            >{statusCode} AVG Requests Per Minute: {rps == null || Number.isNaN(rps)
+                                                ? "–"
+                                                : rps}
+                                        </TooltipDataRow>
+                                    );
+                                })}
+                            </>
+                        )}
+                    />
+                </XYChart>
+                <AxisBottom
+                    top={height - 20}
+                    scale={dateScale}
+                    tickFormat={formats[resolution]}
+                    hideAxisLine={true}
+                    hideTicks={true}
+                    tickLabelProps={() => ({
+                        fill: "white",
+                        fontSize: 11,
+                        textAnchor: "middle",
+                        fillOpacity: 0.4,
+                    })}
+                />
+            </svg>
+        </div>
+    )
+};
+
+export default StackedAreaChart;
+
+const TooltipDate = styled.div`
+  text-align: center;
+  margin-bottom: 8px;
+`;
+
+const TooltipDataRow = styled.div<{ color?: string }>`
+  color: ${(props) => props.color ?? accentColor};
+  margin-bottom: 4px;
+`;

+ 44 - 0
dashboard/src/main/home/app-dashboard/expanded-app/metrics/StatusCodeDataLegend.tsx

@@ -0,0 +1,44 @@
+import React from "react";
+import styled from "styled-components";
+import chroma from "chroma-js";
+import { StatusCodeDataColors } from "./utils";
+
+interface StatusCodeDataLegendProps {}
+
+const StatusCodeDataLegend = ({ }: StatusCodeDataLegendProps) => {
+  const statusCodes = ["1xx", "2xx", "3xx", "4xx", "5xx"];
+
+  return (
+    <StatusCodeDataContainer>
+      {statusCodes.map((key) => (
+        <StatusCodeDataItem key={key}>
+          <DataBar color={StatusCodeDataColors[key]} />
+          {key.toUpperCase()}
+        </StatusCodeDataItem>
+      ))}
+    </StatusCodeDataContainer>
+  );
+};
+
+export default StatusCodeDataLegend;
+
+const StatusCodeDataContainer = styled.div`
+  display: flex;
+  margin-block: 8px;
+`;
+
+const StatusCodeDataItem = 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()};
+`;

+ 28 - 0
dashboard/src/main/home/app-dashboard/expanded-app/metrics/themes/area.tsx

@@ -0,0 +1,28 @@
+import { buildChartTheme, grayColors } from '@visx/xychart';
+
+let chart = buildChartTheme({
+    backgroundColor: '#222',
+    colors: [
+        "#4B4F7C", // gray (1xx)
+        "#FFFFFF", // white (2xx)
+        "#54B835", // green (3xx)
+        "#BBBB3C", // yellow (4xx)
+        "#9C20A5", // purple (5xx)
+    ],
+    tickLength: 4,
+    svgLabelSmall: {
+        fill: grayColors[2],
+    },
+    svgLabelBig: {
+        fill: grayColors[0],
+    },
+    gridColor: grayColors[4],
+    gridColorDark: grayColors[1],
+});
+
+chart.gridStyles.strokeDasharray = "1,3";
+chart.gridStyles.stroke = "white";
+chart.gridStyles.strokeOpacity = 0.2;
+
+
+export default chart;

+ 30 - 0
dashboard/src/main/home/app-dashboard/expanded-app/metrics/utils.tsx → dashboard/src/main/home/app-dashboard/expanded-app/metrics/utils.ts

@@ -1,3 +1,12 @@
+// these match log colors
+export const StatusCodeDataColors: Record<string, string> = {
+    "1xx": "#4B4F7C", // gray
+    "2xx": "#FFFFFF", // white
+    "3xx": "#54B835", // green
+    "4xx": "#BBBB3C", // yellow
+    "5xx": "#9C20A5", // purple
+};
+
 type RGB = {
     r: number;
     g: number;
@@ -55,3 +64,24 @@ export function pickColor(
 
     return rgbToHex(rgb);
 }
+
+export const getServiceNameFromControllerName = (controllerName: string, porterAppName: string): string => {
+    const prefix = `${porterAppName}-`;
+
+    if (!controllerName.startsWith(prefix)) {
+        return "";
+    }
+
+    controllerName = controllerName.substring(prefix.length);
+
+    const suffixes = ["-web", "-wkr", "-job"];
+    let index = -1;
+
+    for (const suffix of suffixes) {
+        const newIndex = controllerName.lastIndexOf(suffix);
+        index = Math.max(index, newIndex);
+    }
+
+    return index !== -1 ? controllerName.substring(0, index) : controllerName;
+}
+

+ 25 - 0
dashboard/src/main/home/cluster-dashboard/expanded-chart/metrics/MetricNormalizer.ts

@@ -6,10 +6,12 @@ import {
   MetricsMemoryDataResponse,
   MetricsNetworkDataResponse,
   MetricsNGINXErrorsDataResponse,
+  MetricsNGINXStatusDataResponse,
   AvailableMetrics,
   MetricsHpaReplicasDataResponse,
   MetricsNGINXLatencyDataResponse,
   NormalizedMetricsData,
+  NormalizedNginxStatusMetricsData,
 } from "./types";
 
 /**
@@ -54,6 +56,14 @@ export class MetricNormalizer {
     return [];
   }
 
+  getNginxStatusData(): NormalizedNginxStatusMetricsData[] {
+    if (this.kind.includes("nginx:status")) {
+      return this.parseNGINXStatusMetrics(this.metric_results);
+    }
+
+    return []
+  }
+
   getAggregatedData(): Record<string, NormalizedMetricsData[]> {
     const groupedByDate = _.groupBy(this.getParsedData(), "date");
 
@@ -126,6 +136,21 @@ export class MetricNormalizer {
     });
   }
 
+  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"]
   ) {

+ 27 - 0
dashboard/src/main/home/cluster-dashboard/expanded-chart/metrics/types.ts

@@ -38,6 +38,18 @@ export type MetricsNGINXLatencyDataResponse = {
   }[];
 };
 
+export type MetricsNGINXStatusDataResponse = {
+  pod?: string;
+  results: {
+    "1xx": string;
+    "2xx": string;
+    "3xx": string;
+    "4xx": string;
+    "5xx": string;
+    date: number;
+  }[];
+}
+
 export type MetricsHpaReplicasDataResponse = {
   pod?: string;
   results: {
@@ -49,6 +61,11 @@ export type MetricsHpaReplicasDataResponse = {
 export type GenericMetricResponse = {
   pod?: string;
   results: {
+    "1xx": string;
+    "2xx": string;
+    "3xx": string;
+    "4xx": string;
+    "5xx": string;
     date: number;
     cpu: string;
     memory: string;
@@ -64,12 +81,22 @@ export type NormalizedMetricsData = {
   value: number; // value
 };
 
+export type NormalizedNginxStatusMetricsData = {
+  date: number; // unix timestamp
+  "1xx": number;
+  "2xx": number;
+  "3xx": number;
+  "4xx": number;
+  "5xx": number;
+};
+
 export type AvailableMetrics =
   | "cpu"
   | "memory"
   | "network"
   | "nginx:errors"
   | "nginx:latency"
+  | "nginx:status"
   | "cpu_hpa_threshold"
   | "memory_hpa_threshold"
   | "hpa_replicas";

+ 76 - 23
internal/kubernetes/prometheus/metrics.go

@@ -5,6 +5,7 @@ import (
 	"encoding/json"
 	"errors"
 	"fmt"
+	"sort"
 	"strings"
 
 	v1 "k8s.io/api/core/v1"
@@ -116,8 +117,6 @@ type QueryOpts struct {
 	PodList   []string `schema:"pods"`
 	Name      string   `schema:"name"`
 	Namespace string   `schema:"namespace"`
-	// a prefix [1,2,3,4,5] used to scope nginx requests by when querying for status code responses
-	NginxStatusLevel uint `schema:"nginx_status_level"`
 	// start time (in unix timestamp) for prometheus results
 	StartRange uint `schema:"startrange"`
 	// end time time (in unix timestamp) for prometheus results
@@ -240,19 +239,7 @@ func QueryPrometheus(
 }
 
 func getNginxStatusQuery(opts *QueryOpts, selectionRegex string) (string, error) {
-	supportedLevels := map[int]bool{
-		1: true,
-		2: true,
-		3: true,
-		4: true,
-		5: true,
-	}
-
-	if !supportedLevels[int(opts.NginxStatusLevel)] {
-		return "", errors.New("invalid nginx status level specified")
-	}
-
-	query := fmt.Sprintf(`round(sum by (ingress)(irate(nginx_ingress_controller_requests{exported_namespace=~"%s",ingress="%s",service="%s",status=~"%d.."}[5m])), 0.001)`, opts.Namespace, selectionRegex, opts.Name, opts.NginxStatusLevel)
+	query := fmt.Sprintf(`round(sum by (status_code, ingress)(label_replace(increase(nginx_ingress_controller_requests{exported_namespace=~"%s",ingress="%s",service="%s"}[2m]), "status_code", "${1}xx", "status", "(.)..")), 0.001)`, opts.Namespace, selectionRegex, opts.Name)
 	return query, nil
 }
 
@@ -260,7 +247,8 @@ type promRawQuery struct {
 	Data struct {
 		Result []struct {
 			Metric struct {
-				Pod string `json:"pod,omitempty"`
+				Pod        string `json:"pod,omitempty"`
+				StatusCode string `json:"status_code,omitempty"`
 			} `json:"metric,omitempty"`
 
 			Values [][]interface{} `json:"values"`
@@ -269,13 +257,18 @@ type promRawQuery struct {
 }
 
 type promParsedSingletonQueryResult struct {
-	Date     interface{} `json:"date,omitempty"`
-	CPU      interface{} `json:"cpu,omitempty"`
-	Replicas interface{} `json:"replicas,omitempty"`
-	Memory   interface{} `json:"memory,omitempty"`
-	Bytes    interface{} `json:"bytes,omitempty"`
-	ErrorPct interface{} `json:"error_pct,omitempty"`
-	Latency  interface{} `json:"latency,omitempty"`
+	Date          interface{} `json:"date,omitempty"`
+	CPU           interface{} `json:"cpu,omitempty"`
+	Replicas      interface{} `json:"replicas,omitempty"`
+	Memory        interface{} `json:"memory,omitempty"`
+	Bytes         interface{} `json:"bytes,omitempty"`
+	ErrorPct      interface{} `json:"error_pct,omitempty"`
+	Latency       interface{} `json:"latency,omitempty"`
+	StatusCode1xx interface{} `json:"1xx,omitempty"`
+	StatusCode2xx interface{} `json:"2xx,omitempty"`
+	StatusCode3xx interface{} `json:"3xx,omitempty"`
+	StatusCode4xx interface{} `json:"4xx,omitempty"`
+	StatusCode5xx interface{} `json:"5xx,omitempty"`
 }
 
 type promParsedSingletonQuery struct {
@@ -284,6 +277,10 @@ type promParsedSingletonQuery struct {
 }
 
 func parseQuery(rawQuery []byte, metric string) ([]*promParsedSingletonQuery, error) {
+	if metric == "nginx:status" {
+		return parseNginxStatusQuery(rawQuery)
+	}
+
 	rawQueryObj := &promRawQuery{}
 
 	err := json.Unmarshal(rawQuery, rawQueryObj)
@@ -334,6 +331,62 @@ func parseQuery(rawQuery []byte, metric string) ([]*promParsedSingletonQuery, er
 	return res, nil
 }
 
+func parseNginxStatusQuery(rawQuery []byte) ([]*promParsedSingletonQuery, error) {
+	rawQueryObj := &promRawQuery{}
+
+	err := json.Unmarshal(rawQuery, rawQueryObj)
+	if err != nil {
+		return nil, err
+	}
+
+	singletonResultsByDate := make(map[string]*promParsedSingletonQueryResult, 0)
+	keys := make([]string, 0)
+	for _, result := range rawQueryObj.Data.Result {
+		for _, values := range result.Values {
+			date := values[0]
+			dateKey := fmt.Sprintf("%v", date)
+
+			if _, ok := singletonResultsByDate[dateKey]; !ok {
+				keys = append(keys, dateKey)
+				singletonResultsByDate[dateKey] = &promParsedSingletonQueryResult{
+					Date: date,
+				}
+			}
+
+			switch result.Metric.StatusCode {
+			case "1xx":
+				singletonResultsByDate[dateKey].StatusCode1xx = values[1]
+			case "2xx":
+				singletonResultsByDate[dateKey].StatusCode2xx = values[1]
+			case "3xx":
+				singletonResultsByDate[dateKey].StatusCode3xx = values[1]
+			case "4xx":
+				singletonResultsByDate[dateKey].StatusCode4xx = values[1]
+			case "5xx":
+				singletonResultsByDate[dateKey].StatusCode5xx = values[1]
+			default:
+				return nil, errors.New("invalid nginx status code")
+			}
+		}
+	}
+
+	sort.Strings(keys)
+
+	singletonResults := make([]promParsedSingletonQueryResult, 0)
+	for _, k := range keys {
+		singletonResults = append(singletonResults, *singletonResultsByDate[k])
+	}
+
+	singleton := &promParsedSingletonQuery{
+		Results: singletonResults,
+	}
+
+	res := make([]*promParsedSingletonQuery, 0)
+	res = append(res, singleton)
+
+	return res, nil
+}
+
 func getSelectionRegex(kind, name string) (string, error) {
 	var suffix string
 

+ 3 - 29
internal/kubernetes/prometheus/metrics_test.go

@@ -1,7 +1,6 @@
 package prometheus
 
 import (
-	"errors"
 	"testing"
 
 	"github.com/stretchr/testify/assert"
@@ -25,42 +24,17 @@ func Test_getNginxStatusQuery(t *testing.T) {
 			err   error
 		}
 	}{
-		{
-			"missing status level",
-			input{
-				&QueryOpts{},
-				"process-app-web",
-			},
-			output{
-				"",
-				errors.New("invalid nginx status level specified"),
-			},
-		},
-		{
-			"invalid status level",
-			input{
-				&QueryOpts{
-					NginxStatusLevel: 18,
-				},
-				"process-app-web",
-			},
-			output{
-				"",
-				errors.New("invalid nginx status level specified"),
-			},
-		},
 		{
 			"valid status level",
 			input{
 				&QueryOpts{
-					Name:             "process-app-web",
-					Namespace:        "app-namespace",
-					NginxStatusLevel: 2,
+					Name:      "process-app-web",
+					Namespace: "app-namespace",
 				},
 				"process-app-web",
 			},
 			output{
-				`round(sum by (ingress)(irate(nginx_ingress_controller_requests{exported_namespace=~"app-namespace",ingress="process-app-web",service="process-app-web",status=~"2.."}[5m])), 0.001)`,
+				`round(sum by (status_code, ingress)(label_replace(increase(nginx_ingress_controller_requests{exported_namespace=~"app-namespace",ingress="process-app-web",service="process-app-web"}[2m]), "status_code", "${1}xx", "status", "(.)..")), 0.001)`,
 				nil,
 			},
 		},

برخی فایل ها در این مقایسه diff نمایش داده نمی شوند زیرا تعداد فایل ها بسیار زیاد است