Quellcode durchsuchen

Merge pull request #1063 from porter-dev/master

Latency metrics -> staging
abelanger5 vor 4 Jahren
Ursprung
Commit
c8811f3de5

+ 4 - 2
cli/cmd/docker/agent.go

@@ -200,12 +200,14 @@ func (a *Agent) PushImage(image string) error {
 		opts,
 	)
 
+	if out != nil {
+		defer out.Close()
+	}
+
 	if err != nil {
 		return err
 	}
 
-	defer out.Close()
-
 	termFd, isTerm := term.GetFdInfo(os.Stderr)
 
 	return jsonmessage.DisplayJSONMessagesStream(out, os.Stderr, termFd, isTerm, nil)

+ 5 - 1
dashboard/src/main/home/cluster-dashboard/dashboard/Dashboard.tsx

@@ -10,14 +10,16 @@ import NodeList from "./NodeList";
 import { NamespaceList } from "./NamespaceList";
 import ClusterSettings from "./ClusterSettings";
 import useAuth from "shared/auth/useAuth";
+import Metrics from "./Metrics";
 
-type TabEnum = "nodes" | "settings" | "namespaces";
+type TabEnum = "nodes" | "settings" | "namespaces" | "metrics";
 
 const tabOptions: {
   label: string;
   value: TabEnum;
 }[] = [
   { label: "Nodes", value: "nodes" },
+  { label: "Metrics", value: "metrics" },
   { label: "Namespaces", value: "namespaces" },
   { label: "Settings", value: "settings" },
 ];
@@ -32,6 +34,8 @@ export const Dashboard: React.FunctionComponent = () => {
     switch (currentTab) {
       case "settings":
         return <ClusterSettings />;
+      case "metrics":
+        return <Metrics />;
       case "namespaces":
         return <NamespaceList />;
       case "nodes":

+ 539 - 0
dashboard/src/main/home/cluster-dashboard/dashboard/Metrics.tsx

@@ -0,0 +1,539 @@
+import React, { useContext, useState, useEffect } from "react";
+import { Context } from "../../../../shared/Context";
+import api from "../../../../shared/api";
+import styled from "styled-components";
+import Loading from "../../../../components/Loading";
+import settings from "../../../../assets/settings.svg";
+import TabSelector from "../../../../components/TabSelector";
+import CheckboxRow from "../../../../components/form-components/CheckboxRow";
+import ParentSize from "@visx/responsive/lib/components/ParentSize";
+import AreaChart from "../expanded-chart/metrics/AreaChart";
+import {
+  AvailableMetrics,
+  NormalizedMetricsData,
+} from "../expanded-chart/metrics/types";
+import SelectRow from "../../../../components/form-components/SelectRow";
+import { MetricNormalizer } from "../expanded-chart/metrics/MetricNormalizer";
+import {
+  resolutions,
+  secondsBeforeNow,
+} from "../expanded-chart/metrics/MetricsSection";
+
+const Metrics: React.FC = () => {
+  const { currentProject, currentCluster, setCurrentError } = useContext(
+    Context
+  );
+  const [loading, setLoading] = useState(true);
+  const [detected, setDetected] = useState(false);
+  const [metricsOptions, setMetricsOptions] = useState([]);
+  const [dropdownExpanded, setDropdownExpanded] = useState(false);
+  const [ingressOptions, setIngressOptions] = useState([]);
+  const [selectedIngress, setSelectedIngress] = useState(null);
+  const [selectedRange, setSelectedRange] = useState("1H");
+  const [selectedMetric, setSelectedMetric] = useState("nginx:errors");
+  const [selectedMetricLabel, setSelectedMetricLabel] = useState(
+    "5XX Error Percentage"
+  );
+  const [selectedPercentile, setSelectedPercentile] = useState("0.99");
+  const [data, setData] = useState<NormalizedMetricsData[]>([]);
+  const [showMetricsSettings, setShowMetricsSettings] = useState(false);
+  const [isLoading, setIsLoading] = useState(0);
+  const [hpaData, setHpaData] = useState([]);
+
+  useEffect(() => {
+    if (selectedMetric && selectedRange && selectedIngress) {
+      getMetrics();
+    }
+  }, [selectedMetric, selectedRange, selectedIngress, selectedPercentile]);
+
+  useEffect(() => {
+    Promise.all([
+      api.getCluster(
+        "<token>",
+        {},
+        {
+          project_id: currentProject.id,
+          cluster_id: currentCluster.id,
+        }
+      ),
+      api.getPrometheusIsInstalled(
+        "<token>",
+        {
+          cluster_id: currentCluster.id,
+        },
+        {
+          id: currentProject.id,
+        }
+      ),
+    ])
+      .then(() => {
+        setDetected(true);
+        setIsLoading((prev) => prev + 1);
+
+        api
+          .getNGINXIngresses(
+            "<token>",
+            {
+              cluster_id: currentCluster.id,
+            },
+            {
+              id: currentProject.id,
+            }
+          )
+          .then((res) => {
+            const ingressOptions = res.data.map((ingress: any) => ({
+              value: ingress,
+              label: ingress.name,
+            }));
+            setIngressOptions(ingressOptions);
+            setSelectedIngress(ingressOptions[0]?.value);
+            setMetricsOptions([
+              ...metricsOptions,
+              {
+                value: "nginx:errors",
+                label: "5XX Error Percentage",
+              },
+              {
+                value: "nginx:latency",
+                label: "Request Latency (s)",
+              },
+              {
+                value: "nginx:latency-histogram",
+                label: "Percentile Response Times (s)",
+              },
+            ]);
+            setLoading(false);
+          })
+          .catch((err) => {
+            setCurrentError(JSON.stringify(err));
+          })
+          .finally(() => {
+            setIsLoading((prev) => prev - 1);
+          });
+      })
+      .catch(() => {
+        setDetected(false);
+        setLoading(false);
+      });
+  }, []);
+
+  const renderMetricsSettings = () => {
+    if (showMetricsSettings) {
+      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%"
+            />
+            {selectedMetric == "nginx:latency-histogram" && (
+              <SelectRow
+                label="Percentile"
+                value={selectedPercentile}
+                setActiveValue={(x) => {
+                  setSelectedPercentile(x);
+                }}
+                options={[
+                  {
+                    label: "99",
+                    value: "0.99",
+                  },
+                  {
+                    label: "95",
+                    value: "0.95",
+                  },
+                  {
+                    label: "50",
+                    value: "0.5",
+                  },
+                ]}
+                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>
+        );
+      }
+    );
+  };
+
+  const getMetrics = async () => {
+    try {
+      let shouldsum = true;
+      let namespace = "default";
+
+      // 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[];
+
+      podNames = [selectedIngress?.name];
+
+      setIsLoading((prev) => prev + 1);
+      setData([]);
+
+      const res = await api.getMetrics(
+        "<token>",
+        {
+          cluster_id: currentCluster.id,
+          metric: selectedMetric,
+          shouldsum: false,
+          kind: "Ingress",
+          namespace: selectedIngress?.namespace || "default",
+          percentile:
+            selectedMetric == "nginx:latency-histogram"
+              ? parseFloat(selectedPercentile)
+              : undefined,
+          startrange: start,
+          endrange: end,
+          resolution: resolutions[selectedRange],
+          pods: podNames,
+          name: selectedIngress?.name,
+        },
+        {
+          id: currentProject.id,
+        }
+      );
+
+      setHpaData([]);
+
+      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);
+    }
+  };
+
+  return loading ? (
+    <LoadingWrapper>
+      <Loading />
+    </LoadingWrapper>
+  ) : !detected ? (
+    <p>
+      This message displays when either there's no ingress controller or nginx
+      is not installed
+    </p>
+  ) : (
+    <StyledMetricsSection>
+      <Header>
+        <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>
+      </Header>
+      {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 && (
+        <>
+          <ParentSize>
+            {({ width, height }) => (
+              <AreaChart
+                dataKey={selectedMetricLabel}
+                data={data}
+                hpaData={hpaData}
+                hpaEnabled={false}
+                width={width}
+                height={height - 10}
+                resolution={selectedRange}
+                margin={{ top: 40, right: -40, bottom: 0, left: 50 }}
+              />
+            )}
+          </ParentSize>
+        </>
+      )}
+    </StyledMetricsSection>
+  );
+};
+
+export default Metrics;
+
+const LoadingWrapper = styled.div`
+  padding: 30px 0px;
+  display: flex;
+  align-items: center;
+  font-size: 13px;
+  justify-content: center;
+  color: #ffffff44;
+`;
+
+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: 156px;
+  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: 400px;
+  height: 50vh;
+  display: flex;
+  flex-direction: column;
+  position: relative;
+  font-size: 13px;
+  border-radius: 8px;
+  border: 1px solid #ffffff33;
+  padding: 18px 22px;
+  animation: floatIn 0.3s;
+  animation-timing-function: ease-out;
+  animation-fill-mode: forwards;
+  margin-top: 20px;
+  @keyframes floatIn {
+    from {
+      opacity: 0;
+      transform: translateY(10px);
+    }
+    to {
+      opacity: 1;
+      transform: translateY(0px);
+    }
+  }
+`;
+
+const Header = styled.div`
+  font-weight: 500;
+  color: #aaaabb;
+  font-size: 16px;
+  margin-bottom: 15px;
+`;

+ 18 - 3
dashboard/src/main/home/cluster-dashboard/expanded-chart/metrics/MetricNormalizer.ts

@@ -1,12 +1,13 @@
 import {
-  AvailableMetrics,
   GenericMetricResponse,
   MetricsCPUDataResponse,
-  MetricsHpaReplicasDataResponse,
   MetricsMemoryDataResponse,
   MetricsNetworkDataResponse,
   MetricsNGINXErrorsDataResponse,
-  NormalizedMetricsData
+  AvailableMetrics,
+  MetricsHpaReplicasDataResponse, 
+  MetricsNGINXLatencyDataResponse,
+  NormalizedMetricsData,
 } from "./types";
 
 /**
@@ -39,6 +40,9 @@ export class MetricNormalizer {
     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);
     }
@@ -83,6 +87,17 @@ export class MetricNormalizer {
     });
   }
 
+  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"]
   ) {

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

@@ -19,14 +19,14 @@ type PropsType = {
   currentChart: ChartTypeWithExtendedConfig;
 };
 
-const resolutions: { [range: string]: string } = {
+export const resolutions: { [range: string]: string } = {
   "1H": "1s",
   "6H": "15s",
   "1D": "15s",
   "1M": "5h",
 };
 
-const secondsBeforeNow: { [range: string]: number } = {
+export const secondsBeforeNow: { [range: string]: number } = {
   "1H": 60 * 60,
   "6H": 60 * 60 * 6,
   "1D": 60 * 60 * 24,

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

@@ -30,6 +30,14 @@ export type MetricsNGINXErrorsDataResponse = {
   }[];
 };
 
+export type MetricsNGINXLatencyDataResponse = {
+  pod?: string;
+  results: {
+    date: number;
+    latency: string;
+  }[];
+};
+
 export type MetricsHpaReplicasDataResponse = {
   pod?: string;
   results: {
@@ -47,6 +55,7 @@ export type GenericMetricResponse = {
     bytes: string;
     error_pct: string;
     replicas: string;
+    latency: string;
   }[];
 };
 
@@ -60,6 +69,7 @@ export type AvailableMetrics =
   | "memory"
   | "network"
   | "nginx:errors"
+  | "nginx:latency"
   | "cpu_hpa_threshold"
   | "memory_hpa_threshold"
   | "hpa_replicas";

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

@@ -569,7 +569,8 @@ const getMetrics = baseApi<
     shouldsum: boolean;
     pods?: string[];
     kind?: string; // the controller kind
-    name: string;
+    name?: string;
+    percentile?: number;
     namespace: string;
     startrange: number;
     endrange: number;

+ 21 - 9
docs/guides/running-porter-locally.md

@@ -1,19 +1,31 @@
-While it requires a few additional steps, it is possible to run Porter locally. These are the steps to start using Porter on your local machine.
+While it requires a few additional steps, it is possible to run Porter locally. Porter can either be run inside a Docker container, or the binary can be run directly.
+
+## Running the Binary
+
+To run the Porter binary, follow these steps: 
 
 1. [Install our CLI](https://docs.getporter.dev/docs/cli-documentation#installation)
 
 2. Run `porter server start`. This will spin up a local Porter instance on port 8080.
 
-By default, GitHub login and the deploying from GitHub repo is disabled on the local version of Porter - this is due to security reasons. However, you can add these functionalities to your local instance by creating your own GitHub OAuth application. These are the steps to enable the GitHub features on the local version of Porter:
+3. Navigate to http://localhost:8080/register, and create a new user with an email and password. 
 
-1. [Create a new GitHub Oauth App](https://docs.github.com/en/developers/apps/creating-an-oauth-app). This app should be created with `http://localhost:8080/api/oauth/github/callback` as the callback URL. 
+## Running with Docker
 
-2. Copy the Client ID and the Client secrets. Then add these lines into your `.bashrc` file:
+The easiest way to run the Docker container is to use SQLite as the persistence option. To accomplish this, you can simply run:
 
-```txt
-export GITHUB_CLIENT_ID=YOUR_CLIENT_ID
-export GITHUB_CLIENT_SECRET=YOUR_CLIENT_SECRET
-export GITHUB_ENABLED=true
 ```
+docker volume create porter_sqlite
+docker run \
+  --mount type=volume,source=porter_sqlite,target=/sqlite,readonly=false \
+  -e REDIS_ENABLED=false \
+  -e SQL_LITE_PATH=/sqlite/porter.db \
+  -p 8080:8080 \
+  -d porter1/porter:latest
+```
+
+Then navigate to http://localhost:8080/register, and create a new user with an email and password. 
+
+## Setting up Integrations
 
-3. When you run `porter server start`, Porter will automatically read these variables in and enable the GitHub features.
+While basic functionality is supported on the local binary/Docker image, more configuration is required to support various integrations. See [this document](https://docs.porter.run/docs/sso) for instructions on adding integrations like Github application access.

+ 1 - 1
internal/kubernetes/config.go

@@ -312,7 +312,7 @@ func (conf *OutOfClusterConfig) CreateRawConfigFromCluster() (*api.Config, error
 		}
 
 		// add this as a bearer token
-		authInfoMap[authInfoName].Token = tok
+		authInfoMap[authInfoName].Token = tok.AccessToken
 	case models.AWS:
 		awsAuth, err := conf.Repo.AWSIntegration.ReadAWSIntegration(
 			cluster.AWSIntegrationID,

+ 20 - 8
internal/kubernetes/prometheus/metrics.go

@@ -86,6 +86,7 @@ type QueryOpts struct {
 	StartRange uint     `schema:"startrange"`
 	EndRange   uint     `schema:"endrange"`
 	Resolution string   `schema:"resolution"`
+	Percentile float64  `schema:"percentile"`
 }
 
 func QueryPrometheus(
@@ -97,7 +98,7 @@ func QueryPrometheus(
 		return nil, fmt.Errorf("prometheus service has no exposed ports to query")
 	}
 
-	podSelectionRegex, err := getPodSelectionRegex(opts.Kind, opts.Name)
+	selectionRegex, err := getSelectionRegex(opts.Kind, opts.Name)
 
 	if err != nil {
 		return nil, err
@@ -108,7 +109,7 @@ func QueryPrometheus(
 	if len(opts.PodList) > 0 {
 		podSelector = fmt.Sprintf(`namespace="%s",pod=~"%s",container!="POD",container!=""`, opts.Namespace, strings.Join(opts.PodList, "|"))
 	} else {
-		podSelector = fmt.Sprintf(`namespace="%s",pod=~"%s",container!="POD",container!=""`, opts.Namespace, podSelectionRegex)
+		podSelector = fmt.Sprintf(`namespace="%s",pod=~"%s",container!="POD",container!=""`, opts.Namespace, selectionRegex)
 	}
 
 	query := ""
@@ -118,12 +119,18 @@ func QueryPrometheus(
 	} else if opts.Metric == "memory" {
 		query = fmt.Sprintf("container_memory_usage_bytes{%s}", podSelector)
 	} else if opts.Metric == "network" {
-		netPodSelector := fmt.Sprintf(`namespace="%s",pod=~"%s",container="POD"`, opts.Namespace, podSelectionRegex)
+		netPodSelector := fmt.Sprintf(`namespace="%s",pod=~"%s",container="POD"`, opts.Namespace, selectionRegex)
 		query = fmt.Sprintf("rate(container_network_receive_bytes_total{%s}[5m])", netPodSelector)
 	} else if opts.Metric == "nginx:errors" {
-		num := fmt.Sprintf(`sum(rate(nginx_ingress_controller_requests{status=~"5.*",namespace="%s",ingress=~"%s"}[5m]) OR on() vector(0))`, opts.Namespace, podSelectionRegex)
-		denom := fmt.Sprintf(`sum(rate(nginx_ingress_controller_requests{namespace="%s",ingress=~"%s"}[5m]) > 0)`, opts.Namespace, podSelectionRegex)
+		num := fmt.Sprintf(`sum(rate(nginx_ingress_controller_requests{status=~"5.*",namespace="%s",ingress=~"%s"}[5m]) OR on() vector(0))`, opts.Namespace, selectionRegex)
+		denom := fmt.Sprintf(`sum(rate(nginx_ingress_controller_requests{namespace="%s",ingress=~"%s"}[5m]) > 0)`, opts.Namespace, selectionRegex)
 		query = fmt.Sprintf(`%s / %s * 100 OR on() vector(0)`, num, denom)
+	} else if opts.Metric == "nginx:latency" {
+		num := fmt.Sprintf(`sum(rate(nginx_ingress_controller_request_duration_seconds_sum{namespace=~"%s",ingress=~"%s"}[5m]) OR on() vector(0))`, opts.Namespace, selectionRegex)
+		denom := fmt.Sprintf(`sum(rate(nginx_ingress_controller_request_duration_seconds_count{namespace=~"%s",ingress=~"%s"}[5m]))`, opts.Namespace, selectionRegex)
+		query = fmt.Sprintf(`%s / %s OR on() vector(0)`, num, denom)
+	} else if opts.Metric == "nginx:latency-histogram" {
+		query = fmt.Sprintf(`histogram_quantile(%f, sum(rate(nginx_ingress_controller_request_duration_seconds_bucket{status!="404",status!="500",namespace=~"%s",ingress=~"%s"}[5m])) by (le, ingress))`, opts.Percentile, opts.Namespace, selectionRegex)
 	} else if opts.Metric == "cpu_hpa_threshold" {
 		// get the name of the kube hpa metric
 		metricName, hpaMetricName := getKubeHPAMetricName(clientset, service, opts, "spec_target_metric")
@@ -135,7 +142,7 @@ func QueryPrometheus(
 			appLabel = ksmSvc.ObjectMeta.Labels["app.kubernetes.io/instance"]
 		}
 
-		query = createHPAAbsoluteCPUThresholdQuery(cpuMetricName, metricName, podSelectionRegex, opts.Name, opts.Namespace, appLabel, hpaMetricName)
+		query = createHPAAbsoluteCPUThresholdQuery(cpuMetricName, metricName, selectionRegex, opts.Name, opts.Namespace, appLabel, hpaMetricName)
 	} else if opts.Metric == "memory_hpa_threshold" {
 		metricName, hpaMetricName := getKubeHPAMetricName(clientset, service, opts, "spec_target_metric")
 		memMetricName := getKubeMemoryMetricName(clientset, service, opts)
@@ -146,7 +153,7 @@ func QueryPrometheus(
 			appLabel = ksmSvc.ObjectMeta.Labels["app.kubernetes.io/instance"]
 		}
 
-		query = createHPAAbsoluteMemoryThresholdQuery(memMetricName, metricName, podSelectionRegex, opts.Name, opts.Namespace, appLabel, hpaMetricName)
+		query = createHPAAbsoluteMemoryThresholdQuery(memMetricName, metricName, selectionRegex, opts.Name, opts.Namespace, appLabel, hpaMetricName)
 	} else if opts.Metric == "hpa_replicas" {
 		metricName, hpaMetricName := getKubeHPAMetricName(clientset, service, opts, "status_current_replicas")
 		ksmSvc, found, _ := getKubeStateMetricsService(clientset)
@@ -210,6 +217,7 @@ type promParsedSingletonQueryResult struct {
 	Memory   interface{} `json:"memory,omitempty"`
 	Bytes    interface{} `json:"bytes,omitempty"`
 	ErrorPct interface{} `json:"error_pct,omitempty"`
+	Latency  interface{} `json:"latency,omitempty"`
 }
 
 type promParsedSingletonQuery struct {
@@ -250,6 +258,8 @@ func parseQuery(rawQuery []byte, metric string) ([]byte, error) {
 				singletonResult.Memory = values[1]
 			} else if metric == "hpa_replicas" {
 				singletonResult.Replicas = values[1]
+			} else if metric == "nginx:latency" || metric == "nginx:latency-histogram" {
+				singletonResult.Latency = values[1]
 			}
 
 			singletonResults = append(singletonResults, *singletonResult)
@@ -263,7 +273,7 @@ func parseQuery(rawQuery []byte, metric string) ([]byte, error) {
 	return json.Marshal(res)
 }
 
-func getPodSelectionRegex(kind, name string) (string, error) {
+func getSelectionRegex(kind, name string) (string, error) {
 	var suffix string
 
 	switch strings.ToLower(kind) {
@@ -275,6 +285,8 @@ func getPodSelectionRegex(kind, name string) (string, error) {
 		suffix = "[a-z0-9]+"
 	case "cronjob":
 		suffix = "[a-z0-9]+-[a-z0-9]+"
+	case "ingress":
+		return name, nil
 	default:
 		return "", fmt.Errorf("not a supported controller to query for metrics")
 	}

+ 9 - 5
internal/models/integrations/gcp.go

@@ -4,6 +4,7 @@ import (
 	"context"
 	"encoding/json"
 
+	"golang.org/x/oauth2"
 	"golang.org/x/oauth2/google"
 	"gorm.io/gorm"
 )
@@ -83,13 +84,16 @@ func (g *GCPIntegration) GetBearerToken(
 	getTokenCache GetTokenCacheFunc,
 	setTokenCache SetTokenCacheFunc,
 	scopes ...string,
-) (string, error) {
+) (*oauth2.Token, error) {
 	cache, err := getTokenCache()
 
 	// check the token cache for a non-expired token
 	if cache != nil {
 		if tok := cache.Token; err == nil && !cache.IsExpired() && len(tok) > 0 {
-			return string(tok), nil
+			return &oauth2.Token{
+				AccessToken: string(cache.Token),
+				Expiry:      cache.Expiry,
+			}, nil
 		}
 	}
 
@@ -100,19 +104,19 @@ func (g *GCPIntegration) GetBearerToken(
 	)
 
 	if err != nil {
-		return "", err
+		return nil, err
 	}
 
 	tok, err := creds.TokenSource.Token()
 
 	if err != nil {
-		return "", err
+		return nil, err
 	}
 
 	// update the token cache
 	setTokenCache(tok.AccessToken, tok.Expiry)
 
-	return tok.AccessToken, nil
+	return tok, nil
 }
 
 // credentialsFile is the unmarshalled representation of a GCP credentials file.

+ 16 - 7
internal/registry/registry.go

@@ -92,6 +92,8 @@ type gcrRepositoryResp struct {
 }
 
 func (r *Registry) GetGCRToken(repo repository.Repository) (*ints.TokenCache, error) {
+	getTokenCache := r.getTokenCacheFunc(repo)
+
 	gcp, err := repo.GCPIntegration.ReadGCPIntegration(
 		r.GCPIntegrationID,
 	)
@@ -102,7 +104,7 @@ func (r *Registry) GetGCRToken(repo repository.Repository) (*ints.TokenCache, er
 
 	// get oauth2 access token
 	_, err = gcp.GetBearerToken(
-		r.getTokenCache,
+		getTokenCache,
 		r.setTokenCacheFunc(repo),
 		"https://www.googleapis.com/auth/devstorage.read_write",
 	)
@@ -112,7 +114,7 @@ func (r *Registry) GetGCRToken(repo repository.Repository) (*ints.TokenCache, er
 	}
 
 	// it's now written to the token cache, so return
-	cache, err := r.getTokenCache()
+	cache, err := getTokenCache()
 
 	if err != nil {
 		return nil, err
@@ -352,11 +354,18 @@ func (r *Registry) listPrivateRegistryRepositories(
 	return res, nil
 }
 
-func (r *Registry) getTokenCache() (tok *ints.TokenCache, err error) {
-	return &ints.TokenCache{
-		Token:  r.TokenCache.Token,
-		Expiry: r.TokenCache.Expiry,
-	}, nil
+func (r *Registry) getTokenCacheFunc(
+	repo repository.Repository,
+) ints.GetTokenCacheFunc {
+	return func() (tok *ints.TokenCache, err error) {
+		reg, err := repo.Registry.ReadRegistry(r.ID)
+
+		if err != nil {
+			return nil, err
+		}
+
+		return &reg.TokenCache.TokenCache, nil
+	}
 }
 
 func (r *Registry) setTokenCacheFunc(