Просмотр исходного кода

Merge pull request #377 from porter-dev/beta.3.buildpack-ci

Beta.3.buildpack ci
abelanger5 5 лет назад
Родитель
Сommit
28618c492f
29 измененных файлов с 11144 добавлено и 177 удалено
  1. 10378 1
      dashboard/package-lock.json
  2. 1 0
      dashboard/package.json
  3. 2 2
      dashboard/src/components/ResourceTab.tsx
  4. 4 5
      dashboard/src/components/SaveButton.tsx
  5. 2 2
      dashboard/src/components/Selector.tsx
  6. 1 1
      dashboard/src/components/TabRegion.tsx
  7. 1 1
      dashboard/src/components/TooltipParent.tsx
  8. 1 1
      dashboard/src/components/repo-selector/ActionDetails.tsx
  9. 2 2
      dashboard/src/main/CurrentError.tsx
  10. 6 6
      dashboard/src/main/Login.tsx
  11. 5 5
      dashboard/src/main/Main.tsx
  12. 6 6
      dashboard/src/main/Register.tsx
  13. 24 0
      dashboard/src/main/home/cluster-dashboard/ClusterDashboard.tsx
  14. 10 1
      dashboard/src/main/home/cluster-dashboard/expanded-chart/ExpandedChart.tsx
  15. 104 26
      dashboard/src/main/home/cluster-dashboard/expanded-chart/metrics/AreaChart.tsx
  16. 397 20
      dashboard/src/main/home/cluster-dashboard/expanded-chart/metrics/MetricsSection.tsx
  17. 2 2
      dashboard/src/shared/Context.tsx
  18. 3 3
      dashboard/src/shared/ansiparser.tsx
  19. 82 58
      dashboard/src/shared/api.tsx
  20. 6 6
      dashboard/src/shared/baseApi.tsx
  21. 17 17
      dashboard/src/shared/common.tsx
  22. 5 5
      dashboard/src/shared/feedback.tsx
  23. 2 2
      dashboard/src/shared/rosettaStone.tsx
  24. 2 2
      dashboard/src/shared/routing.tsx
  25. 1 1
      dashboard/src/shared/types.tsx
  26. 6 0
      internal/kubernetes/prometheus/metrics.go
  27. 46 0
      server/api/k8s_handler.go
  28. 14 2
      server/api/release_handler.go
  29. 14 0
      server/router/router.go

Разница между файлами не показана из-за своего большого размера
+ 10378 - 1
dashboard/package-lock.json


+ 1 - 0
dashboard/package.json

@@ -12,6 +12,7 @@
     "@types/material-ui": "^0.21.8",
     "@types/qs": "^6.9.5",
     "@types/random-words": "^1.1.0",
+    "@visx/axis": "^1.6.1",
     "@visx/curve": "^1.0.0",
     "@visx/event": "^1.3.0",
     "@visx/gradient": "^1.0.0",

+ 2 - 2
dashboard/src/components/ResourceTab.tsx

@@ -26,7 +26,7 @@ type StateType = {
 export default class ResourceTab extends Component<PropsType, StateType> {
   state = {
     expanded: this.props.expanded || false,
-    showTooltip: false,
+    showTooltip: false
   };
 
   renderDropdownIcon = () => {
@@ -95,7 +95,7 @@ export default class ResourceTab extends Component<PropsType, StateType> {
       handleClick,
       selected,
       status,
-      roundAllCorners,
+      roundAllCorners
     } = this.props;
     return (
       <StyledResourceTab

+ 4 - 5
dashboard/src/components/SaveButton.tsx

@@ -132,15 +132,14 @@ const Button = styled.button`
   text-align: left;
   border: 0;
   border-radius: 5px;
-  background: ${(props) => (!props.disabled ? props.color : "#aaaabb")};
-  box-shadow: ${(props) =>
-    !props.disabled ? "0 2px 5px 0 #00000030" : "none"};
-  cursor: ${(props) => (!props.disabled ? "pointer" : "default")};
+  background: ${props => (!props.disabled ? props.color : "#aaaabb")};
+  box-shadow: ${props => (!props.disabled ? "0 2px 5px 0 #00000030" : "none")};
+  cursor: ${props => (!props.disabled ? "pointer" : "default")};
   user-select: none;
   :focus {
     outline: 0;
   }
   :hover {
-    filter: ${(props) => (!props.disabled ? "brightness(120%)" : "")};
+    filter: ${props => (!props.disabled ? "brightness(120%)" : "")};
   }
 `;

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

@@ -17,7 +17,7 @@ type StateType = {};
 
 export default class Selector extends Component<PropsType, StateType> {
   state = {
-    expanded: false,
+    expanded: false
   };
 
   wrapperRef: any = React.createRef();
@@ -192,7 +192,7 @@ const Dropdown = styled.div`
 
 const StyledSelector = styled.div<{ width: string }>`
   position: relative;
-  width: ${(props) => props.width};
+  width: ${props => props.width};
 `;
 
 const MainSelector = styled.div`

+ 1 - 1
dashboard/src/components/TabRegion.tsx

@@ -27,7 +27,7 @@ export default class TabRegion extends Component<PropsType, StateType> {
   componentDidUpdate(prevProps: PropsType) {
     let { options, currentTab } = this.props;
     if (prevProps.options !== options) {
-      if (options.filter((x) => x.value === currentTab).length === 0) {
+      if (options.filter(x => x.value === currentTab).length === 0) {
         this.props.setCurrentTab(this.defaultTab());
       }
     }

+ 1 - 1
dashboard/src/components/TooltipParent.tsx

@@ -11,7 +11,7 @@ type StateType = {
 
 export default class TooltipParent extends Component<PropsType, StateType> {
   state = {
-    showTooltip: false,
+    showTooltip: false
   };
 
   renderTooltip = (): JSX.Element | undefined => {

+ 1 - 1
dashboard/src/components/repo-selector/ActionDetails.tsx

@@ -78,7 +78,7 @@ export default class ActionDetails extends Component<PropsType, StateType> {
       return (
         <RegistryItem
           key={i}
-          isSelected={registry.id === this.props.selectedRegistry.id}
+          isSelected={this.props.selectedRegistry && registry.id === this.props.selectedRegistry.id}
           lastItem={i === registries.length - 1}
           onClick={() => this.props.setSelectedRegistry(registry)}
         >

+ 2 - 2
dashboard/src/main/CurrentError.tsx

@@ -12,7 +12,7 @@ type StateType = {};
 
 export default class CurrentError extends Component<PropsType, StateType> {
   state = {
-    expanded: false,
+    expanded: false
   };
 
   componentDidUpdate(prevProps: PropsType) {
@@ -32,7 +32,7 @@ export default class CurrentError extends Component<PropsType, StateType> {
           <StyledCurrentError onClick={() => this.setState({ expanded: true })}>
             <ErrorText>Error: {this.props.currentError}</ErrorText>
             <CloseButton
-              onClick={(e) => {
+              onClick={e => {
                 this.context.setCurrentError(null);
                 e.stopPropagation();
               }}

+ 6 - 6
dashboard/src/main/Login.tsx

@@ -23,7 +23,7 @@ export default class Login extends Component<PropsType, StateType> {
     email: "",
     password: "",
     emailError: false,
-    credentialError: false,
+    credentialError: false
   };
 
   handleKeyDown = (e: any) => {
@@ -57,11 +57,11 @@ export default class Login extends Component<PropsType, StateType> {
           "",
           {
             email: email,
-            password: password,
+            password: password
           },
           {}
         )
-        .then((res) => {
+        .then(res => {
           // TODO: case and set credential error
           if (res?.data?.redirect) {
             window.location.href = res.data.redirect;
@@ -70,7 +70,7 @@ export default class Login extends Component<PropsType, StateType> {
             authenticate();
           }
         })
-        .catch((err) =>
+        .catch(err =>
           this.context.setCurrentError(err.response.data.errors[0])
         );
     }
@@ -137,7 +137,7 @@ export default class Login extends Component<PropsType, StateType> {
                   this.setState({
                     email: e.target.value,
                     emailError: false,
-                    credentialError: false,
+                    credentialError: false
                   })
                 }
                 valid={!credentialError && !emailError}
@@ -152,7 +152,7 @@ export default class Login extends Component<PropsType, StateType> {
                 onChange={(e: ChangeEvent<HTMLInputElement>) =>
                   this.setState({
                     password: e.target.value,
-                    credentialError: false,
+                    credentialError: false
                   })
                 }
                 valid={!credentialError}

+ 5 - 5
dashboard/src/main/Main.tsx

@@ -24,7 +24,7 @@ export default class Main extends Component<PropsType, StateType> {
   state = {
     loading: true,
     isLoggedIn: false,
-    initialized: localStorage.getItem("init") === "true",
+    initialized: localStorage.getItem("init") === "true"
   };
 
   componentDidMount() {
@@ -34,19 +34,19 @@ export default class Main extends Component<PropsType, StateType> {
     error && setCurrentError(error);
     api
       .checkAuth("", {}, {})
-      .then((res) => {
+      .then(res => {
         if (res && res.data) {
           setUser(res?.data?.id, res?.data?.email);
           this.setState({
             isLoggedIn: true,
             initialized: true,
-            loading: false,
+            loading: false
           });
         } else {
           this.setState({ isLoggedIn: false, loading: false });
         }
       })
-      .catch((err) => this.setState({ isLoggedIn: false, loading: false }));
+      .catch(err => this.setState({ isLoggedIn: false, loading: false }));
   }
 
   initialize = () => {
@@ -106,7 +106,7 @@ export default class Main extends Component<PropsType, StateType> {
         />
         <Route
           path={`/:baseRoute`}
-          render={(routeProps) => {
+          render={routeProps => {
             const baseRoute = routeProps.match.params.baseRoute;
             if (
               this.state.isLoggedIn &&

+ 6 - 6
dashboard/src/main/Register.tsx

@@ -25,7 +25,7 @@ export default class Register extends Component<PropsType, StateType> {
     password: "",
     confirmPassword: "",
     emailError: false,
-    confirmPasswordError: false,
+    confirmPasswordError: false
   };
 
   handleKeyDown = (e: any) => {
@@ -66,7 +66,7 @@ export default class Register extends Component<PropsType, StateType> {
           "",
           {
             email: email,
-            password: password,
+            password: password
           },
           {}
         )
@@ -78,7 +78,7 @@ export default class Register extends Component<PropsType, StateType> {
             authenticate();
           }
         })
-        .catch((err) => setCurrentError(err.response.data.errors[0]));
+        .catch(err => setCurrentError(err.response.data.errors[0]));
     }
   };
 
@@ -112,7 +112,7 @@ export default class Register extends Component<PropsType, StateType> {
       password,
       confirmPassword,
       emailError,
-      confirmPasswordError,
+      confirmPasswordError
     } = this.state;
 
     return (
@@ -154,7 +154,7 @@ export default class Register extends Component<PropsType, StateType> {
               onChange={(e: ChangeEvent<HTMLInputElement>) =>
                 this.setState({
                   password: e.target.value,
-                  confirmPasswordError: false,
+                  confirmPasswordError: false
                 })
               }
               valid={true}
@@ -167,7 +167,7 @@ export default class Register extends Component<PropsType, StateType> {
                 onChange={(e: ChangeEvent<HTMLInputElement>) =>
                   this.setState({
                     confirmPassword: e.target.value,
-                    confirmPasswordError: false,
+                    confirmPasswordError: false
                   })
                 }
                 valid={!confirmPasswordError}

+ 24 - 0
dashboard/src/main/home/cluster-dashboard/ClusterDashboard.tsx

@@ -11,6 +11,8 @@ import SortSelector from "./SortSelector";
 import ExpandedChart from "./expanded-chart/ExpandedChart";
 import { RouteComponentProps, withRouter } from "react-router";
 
+import api from "shared/api";
+
 type PropsType = RouteComponentProps & {
   currentCluster: ClusterType;
   setSidebar: (x: boolean) => void;
@@ -20,6 +22,7 @@ type StateType = {
   namespace: string;
   sortType: string;
   currentChart: ChartType | null;
+  isMetricsInstalled: boolean;
 };
 
 class ClusterDashboard extends Component<PropsType, StateType> {
@@ -29,8 +32,28 @@ class ClusterDashboard extends Component<PropsType, StateType> {
       ? localStorage.getItem("SortType")
       : "Newest",
     currentChart: null as ChartType | null,
+    isMetricsInstalled: false,
   };
 
+  componentDidMount() {
+    api
+      .getPrometheusIsInstalled(
+        "<token>",
+        {
+          cluster_id: this.context.currentCluster.id,
+        },
+        {
+          id: this.context.currentProject.id,
+        }
+      )
+      .then((res) => {
+        this.setState({ isMetricsInstalled: true });
+      })
+      .catch(() => {
+        this.setState({ isMetricsInstalled: false });
+      });
+  }
+
   componentDidUpdate(prevProps: PropsType) {
     localStorage.setItem("SortType", this.state.sortType);
     // Reset namespace filter and close expanded chart on cluster change
@@ -77,6 +100,7 @@ class ClusterDashboard extends Component<PropsType, StateType> {
           setCurrentChart={(x: ChartType | null) =>
             this.setState({ currentChart: x })
           }
+          isMetricsInstalled={this.state.isMetricsInstalled}
           setSidebar={setSidebar}
         />
       );

+ 10 - 1
dashboard/src/main/home/cluster-dashboard/expanded-chart/ExpandedChart.tsx

@@ -33,6 +33,7 @@ type PropsType = {
   currentCluster: ClusterType;
   setCurrentChart: (x: ChartType | null) => void;
   setSidebar: (x: boolean) => void;
+  isMetricsInstalled: boolean;
 };
 
 type StateType = {
@@ -371,7 +372,15 @@ export default class ExpandedChart extends Component<PropsType, StateType> {
     // Append universal tabs
     tabOptions.push(
       { label: "Status", value: "status" },
-      //{ label: "Metrics", value: "metrics" },
+    );
+
+    if (this.props.isMetricsInstalled) {
+      tabOptions.push(
+        { label: "Metrics", value: "metrics" },
+      )
+    }
+
+    tabOptions.push(
       { label: "Chart Overview", value: "graph" }
     );
 

+ 104 - 26
dashboard/src/main/home/cluster-dashboard/expanded-chart/metrics/AreaChart.tsx

@@ -1,14 +1,18 @@
 import React, { useMemo, useCallback } from "react";
 import { AreaClosed, Line, Bar } from "@visx/shape";
-import appleStock, { AppleStock } from "@visx/mock-data/lib/mocks/appleStock";
 import { curveMonotoneX } from "@visx/curve";
 import { scaleTime, scaleLinear } from "@visx/scale";
+import { AxisLeft, AxisBottom } from '@visx/axis';
+
 import {
   withTooltip,
   Tooltip,
   TooltipWithBounds,
   defaultStyles,
 } from "@visx/tooltip";
+
+import { GridRows, GridColumns } from '@visx/grid';
+
 import { WithTooltipProvidedProps } from "@visx/tooltip/lib/enhancers/withTooltip";
 import { localPoint } from "@visx/event";
 import { LinearGradient } from "@visx/gradient";
@@ -20,9 +24,16 @@ export const accentColor = '#f5cb42';
 export const accentColorDark = '#949eff';
 */
 
-type TooltipData = AppleStock;
 
-const stock = appleStock.slice(800);
+export type MetricsData = {
+  date: number; // unix timestamp
+  value: number; // value 
+}
+
+type TooltipData = MetricsData;
+
+var globalData : MetricsData[]
+
 export const background = "#3b697800";
 export const background2 = "#20405100";
 export const accentColor = "#949eff";
@@ -35,14 +46,28 @@ const tooltipStyles = {
 };
 
 // util
-const formatDate = timeFormat("%b %d, '%y");
+const formatDate = timeFormat("%H:%M:%S %b %d, '%y");
+
+const hourFormat = timeFormat("%H:%M");
+const dayFormat = timeFormat("%b %d");
+
+// map resolutions to formats
+const formats : { [range: string]: (date: Date) => string } = {
+  "1H": hourFormat,
+  "6H": hourFormat,
+  "1D": hourFormat,
+  "1M": dayFormat,
+}
 
 // accessors
-const getDate = (d: AppleStock) => new Date(d.date);
-const getStockValue = (d: AppleStock) => d.close;
-const bisectDate = bisector<AppleStock, Date>((d) => new Date(d.date)).left;
+const getDate = (d: MetricsData) => new Date(d.date*1000);
+const getValue = (d: MetricsData) => d.value;
+
+const bisectDate = bisector<MetricsData, Date>((d) => new Date(d.date*1000)).left;
 
 export type AreaProps = {
+  data: MetricsData[];
+  resolution: string;
   width: number;
   height: number;
   margin?: { top: number; right: number; bottom: number; left: number };
@@ -50,6 +75,8 @@ export type AreaProps = {
 
 export default withTooltip<AreaProps, TooltipData>(
   ({
+    data,
+    resolution,
     width,
     height,
     margin = { top: 0, right: 0, bottom: 0, left: 0 },
@@ -59,10 +86,14 @@ export default withTooltip<AreaProps, TooltipData>(
     tooltipTop = 0,
     tooltipLeft = 0,
   }: AreaProps & WithTooltipProvidedProps<TooltipData>) => {
-    if (width < 10) return null;
+    globalData = data
+
+    if (width == 0 || height == 0 || width < 10) {
+      return null
+    }
 
     // bounds
-    const innerWidth = width - margin.left - margin.right;
+    const innerWidth = width - margin.left - margin.right - 40;
     const innerHeight = height - margin.top - margin.bottom - 20;
 
     // scales
@@ -70,18 +101,18 @@ export default withTooltip<AreaProps, TooltipData>(
       () =>
         scaleTime({
           range: [margin.left, innerWidth + margin.left],
-          domain: extent(stock, getDate) as [Date, Date],
+          domain: extent(globalData, getDate) as [Date, Date],
         }),
-      [innerWidth, margin.left]
+      [innerWidth, margin.left, width, height, data]
     );
-    const stockValueScale = useMemo(
+    const valueScale = useMemo(
       () =>
         scaleLinear({
           range: [innerHeight + margin.top, margin.top],
-          domain: [0, (max(stock, getStockValue) || 0) + innerHeight / 3],
+          domain: [0, 1.25 * max(globalData, getValue)],
           nice: true,
         }),
-      [margin.top, innerHeight]
+      [margin.top, innerHeight, width, height, data]
     );
 
     // tooltip handler
@@ -93,10 +124,11 @@ export default withTooltip<AreaProps, TooltipData>(
       ) => {
         const { x } = localPoint(event) || { x: 0 };
         const x0 = dateScale.invert(x);
-        const index = bisectDate(stock, x0, 1);
-        const d0 = stock[index - 1];
-        const d1 = stock[index];
+        const index = bisectDate(globalData, x0, 1);
+        const d0 = globalData[index - 1];
+        const d1 = globalData[index];
         let d = d0;
+
         if (d1 && getDate(d1)) {
           d =
             x0.valueOf() - getDate(d0).valueOf() >
@@ -104,14 +136,15 @@ export default withTooltip<AreaProps, TooltipData>(
               ? d1
               : d0;
         }
+
         showTooltip({
           tooltipData: d,
-          tooltipLeft: x,
-          tooltipTop: stockValueScale(getStockValue(d)),
+          tooltipLeft: x || 0,
+          tooltipTop: valueScale(getValue(d)) || 0,
         });
       },
-      [showTooltip, stockValueScale, dateScale]
-    );
+      [showTooltip, valueScale, dateScale, width, height, data]
+    )
 
     return (
       <div>
@@ -135,16 +168,61 @@ export default withTooltip<AreaProps, TooltipData>(
             to={accentColor}
             toOpacity={0}
           />
-          <AreaClosed<AppleStock>
-            data={stock}
+          <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"
+          />
+          <AreaClosed<MetricsData>
+            data={data}
             x={(d) => dateScale(getDate(d)) ?? 0}
-            y={(d) => stockValueScale(getStockValue(d)) ?? 0}
-            yScale={stockValueScale}
+            y={(d) => valueScale(getValue(d)) ?? 0}
+            height={innerHeight}
+            yScale={valueScale}
             strokeWidth={1}
             stroke="url(#area-gradient)"
             fill="url(#area-gradient)"
             curve={curveMonotoneX}
           />
+          <AxisLeft
+            left={10}
+            scale={valueScale}
+            hideAxisLine={true}
+            hideTicks={true}
+            tickLabelProps={() => ({
+              fill: "white",
+              fontSize: 11,
+              textAnchor: 'start',
+              fillOpacity: 0.4,
+              dy: 0,
+            })}
+          />
+          <AxisBottom
+            top={height - 20}
+            scale={dateScale}
+            tickFormat={formats[resolution]}
+            hideAxisLine={true}
+            hideTicks={true}
+            tickLabelProps={() => ({
+              fill: "white",
+              fontSize: 11,
+              textAnchor: 'middle',
+              fillOpacity: 0.4,
+            })}
+          />
           <Bar
             x={margin.left}
             y={margin.top}
@@ -198,7 +276,7 @@ export default withTooltip<AreaProps, TooltipData>(
               left={tooltipLeft + 12}
               style={tooltipStyles}
             >
-              {`$${getStockValue(tooltipData)}`}
+              {getValue(tooltipData)}
             </TooltipWithBounds>
             <Tooltip
               top={-10}

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

@@ -2,29 +2,278 @@ import React, { Component } from "react";
 import styled from "styled-components";
 import ParentSize from "@visx/responsive/lib/components/ParentSize";
 
+import api from "shared/api";
 import { Context } from "shared/Context";
-import { ChartType } from "shared/types";
+import { ChartType, StorageType } from "shared/types";
 
 import TabSelector from "components/TabSelector";
-import AreaChart from "./AreaChart";
+import AreaChart, { MetricsData } from "./AreaChart";
 
 type PropsType = {
   currentChart: ChartType;
 };
 
 type StateType = {
+  controllers: any[];
+  selectedController: any;
+  pods: string[];
+  selectedPod: string;
   selectedRange: string;
+  selectedMetric: string;
   selectedMetricLabel: string;
+  controllerDropdownExpanded: boolean;
+  podDropdownExpanded: boolean;
   dropdownExpanded: boolean;
+  data: MetricsData[];
 };
 
-export default class ListSection extends Component<PropsType, StateType> {
+type MetricsCPUDataResponse = {
+  pod?: string,
+  results: {
+    date: number,
+    cpu: string,
+  }[],
+}[]
+
+type MetricsMemoryDataResponse = {
+  pod?: string,
+  results: {
+    date: number,
+    memory: string,
+  }[],
+}[]
+
+type MetricsNetworkDataResponse = {
+  pod?: string,
+  results: {
+    date: number,
+    bytes: string,
+  }[],
+}[]
+
+const resolutions : { [range: string]: string } = {
+  "1H": "15s",
+  "6H": "15s",
+  "1D": "15s",
+  "1M": "5h",
+}
+
+const secondsBeforeNow : { [range: string]: number } = {
+  "1H": 60 * 60,
+  "6H": 60 * 60 * 6,
+  "1D": 60 * 60 * 24,
+  "1M": 60 * 60 * 24 * 30,
+}
+
+export default class MetricsSection extends Component<PropsType, StateType> {
   state = {
+    pods: [] as string[],
+    selectedPod: "",
+    controllers: [] as any[],
+    selectedController: null as any,
     selectedRange: "1H",
-    selectedMetricLabel: "CPU Utilization",
+    selectedMetric: "cpu",
+    selectedMetricLabel: "CPU Utilization (vCPUs)",
     dropdownExpanded: false,
+    podDropdownExpanded: false,
+    controllerDropdownExpanded: false,
+    data: [] as MetricsData[],
   };
 
+  componentDidMount() {
+    // get all controllers and read in a list of pods
+    let { currentChart } = this.props;
+    let { currentCluster, currentProject, setCurrentError } = this.context;
+
+    api
+      .getChartControllers(
+        "<token>",
+        {
+          namespace: currentChart.namespace,
+          cluster_id: currentCluster.id,
+          storage: StorageType.Secret,
+        },
+        {
+          id: currentProject.id,
+          name: currentChart.name,
+          revision: currentChart.version,
+        }
+      )
+      .then((res) => {
+        // TODO -- check at least one controller returned
+
+        // iterate through the controllers to get the list of pods
+        this.setState({ controllers: res.data, selectedController: res.data[0] });
+        
+        this.getPods()
+      })
+      .catch((err) => {
+        setCurrentError(JSON.stringify(err));
+        this.setState({ controllers: [] });
+      });      
+  }
+
+  componentDidUpdate(prevProps: PropsType, prevState: StateType) {
+    // if resolution, data kind, controllers, or pods have changed, update data
+    if (this.state.selectedMetric != prevState.selectedMetric) {
+      this.getMetrics()
+    }
+
+    if (this.state.selectedRange != prevState.selectedRange) {
+      this.getMetrics()
+    }
+
+    if (this.state.selectedPod != prevState.selectedPod) {
+      this.getMetrics()
+    }
+
+    if (this.state.selectedController?.metadata?.name != prevState.selectedController?.metadata?.name) {
+      this.getMetrics()
+    }
+  }
+
+  getMetrics = () => {
+    if (this.state.pods.length == 0) {
+      return
+    }
+
+    let { currentChart } = this.props;
+    let { currentCluster, currentProject, setCurrentError } = this.context;
+    let kind = this.state.selectedMetric
+    let shouldsum = true
+
+    // calculate start and end range
+    var d = new Date();
+    var end = Math.round(d.getTime() / 1000);
+    var start = end - secondsBeforeNow[this.state.selectedRange]
+
+    let pods = this.state.pods
+
+    if (this.state.selectedPod != "All") {
+      pods = [this.state.selectedPod]
+    }
+
+    api
+      .getMetrics(
+        "<token>",
+        {
+          cluster_id: currentCluster.id,
+          metric: kind,
+          shouldsum: shouldsum,
+          pods: pods,
+          namespace: currentChart.namespace,
+          startrange: start,
+          endrange: end,
+          resolution: resolutions[this.state.selectedRange],
+        },
+        {
+          id: currentProject.id,
+        }
+      )
+      .then((res) => {
+        // transform the metrics to expected form
+        if (kind == "cpu") {
+          let data = res.data as MetricsCPUDataResponse
+          
+          // if summed, just look at the first data
+            let tData = data[0].results.map(
+              (d: {
+                date: number,
+                cpu: string,
+              }, i: number) => {
+                return {
+                  date: d.date,
+                  value: parseFloat(d.cpu),
+                }
+              }
+            )
+
+            this.setState({ data: tData })
+        } else if (kind == "memory") {
+          let data = res.data as MetricsMemoryDataResponse
+
+          let tData = data[0].results.map(
+            (d: {
+              date: number,
+              memory: string,
+            }, i: number) => {
+              return {
+                date: d.date,
+                value: parseFloat(d.memory) / (1024 * 1024), // put units in Mi
+              }
+            }
+          )
+
+          this.setState({ data: tData })
+        } else if (kind == "network") {
+          let data = res.data as MetricsNetworkDataResponse
+
+          let tData = data[0].results.map(
+            (d: {
+              date: number,
+              bytes: string,
+            }, i: number) => {
+              return {
+                date: d.date,
+                value: parseFloat(d.bytes) / (1024), // put units in Ki
+              }
+            }
+          )
+
+          this.setState({ data: tData })
+        }
+      })
+      .catch((err) => {
+        setCurrentError(JSON.stringify(err));
+        // this.setState({ controllers: [], loading: false });
+      });
+  }
+
+  getPods = () => {
+    let { selectedController } = this.state;
+    let { currentCluster, currentProject, setCurrentError } = this.context;
+
+    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);
+
+    api
+      .getMatchingPods(
+        "<token>",
+        {
+          cluster_id: currentCluster.id,
+          selectors,
+        },
+        {
+          id: currentProject.id,
+        }
+      )
+      .then((res) => {
+        let pods = res?.data?.map((pod: any) => {
+          return pod?.metadata?.name
+        });
+
+        this.setState({ pods, selectedPod: "All" });
+
+        this.getMetrics()
+      })
+      .catch((err) => {
+        console.log(err);
+        setCurrentError(JSON.stringify(err));
+        return;
+      });
+  }
+
   renderDropdown = () => {
     if (this.state.dropdownExpanded) {
       return (
@@ -33,7 +282,7 @@ export default class ListSection extends Component<PropsType, StateType> {
             onClick={() => this.setState({ dropdownExpanded: false })}
           />
           <Dropdown
-            dropdownWidth="200px"
+            dropdownWidth="230px"
             dropdownMaxHeight="200px"
             onClick={() => this.setState({ dropdownExpanded: false })}
           >
@@ -44,18 +293,106 @@ export default class ListSection extends Component<PropsType, StateType> {
     }
   };
 
+  renderPodDropdown = () => {
+    if (this.state.podDropdownExpanded) {
+      return (
+        <>
+          <DropdownOverlay
+            onClick={() => this.setState({ podDropdownExpanded: false })}
+          />
+          <Dropdown
+            dropdownWidth="400px"
+            dropdownMaxHeight="200px"
+            onClick={() => this.setState({ podDropdownExpanded: false })}
+          >
+            {this.renderPodOptionList()}
+          </Dropdown>
+        </>
+      );
+    }
+  };
+
+  renderPodOptionList = () => {
+    let allPod = [(
+      <Option
+        key={0}
+        selected={"All" === this.state.selectedPod}
+        onClick={() => this.setState({ selectedPod: "All" })}
+        lastItem={false}
+      >
+        All (summed)
+      </Option>
+    )];
+
+    let podOptions = this.state.pods.map(
+      (option: string, i: number) => {
+        return (
+          <Option
+            key={i + 1}
+            selected={option === this.state.selectedPod}
+            onClick={() => this.setState({ selectedPod: option })}
+            lastItem={i === this.state.pods.length - 1}
+          >
+            {option}
+          </Option>
+        );
+      }
+    )
+
+    return allPod.concat(podOptions)
+  };
+
+  renderControllerDropdown = () => {
+    if (this.state.controllerDropdownExpanded) {
+      return (
+        <>
+          <DropdownOverlay
+            onClick={() => this.setState({ controllerDropdownExpanded: false })}
+          />
+          <Dropdown
+            dropdownWidth="300px"
+            dropdownMaxHeight="200px"
+            onClick={() => this.setState({ controllerDropdownExpanded: false })}
+          >
+            {this.renderControllerOptionList()}
+          </Dropdown>
+        </>
+      );
+    }
+  };
+
+  renderControllerOptionList = () => {
+    return this.state.controllers.map(
+      (controller: any, i: number) => {
+        let name = controller?.metadata?.name
+
+        return (
+          <Option
+            key={i}
+            selected={name === this.state.selectedController?.metadata?.name}
+            onClick={() => this.setState({ selectedController: controller })}
+            lastItem={i === this.state.controllers.length - 1}
+          >
+            {name}
+          </Option>
+        );
+      }
+    )
+  };
+
   renderOptionList = () => {
     let metricOptions = [
-      { value: "cpu", label: "CPU Utilization" },
-      { value: "ram", label: "RAM Utilization" },
+      { value: "cpu", label: "CPU Utilization (vCPUs)" },
+      { value: "memory", label: "RAM Utilization (Mi)" },
+      { value: "network", label: "Network Received Bytes (Ki)" },
     ];
     return metricOptions.map(
       (option: { value: string; label: string }, i: number) => {
         return (
           <Option
             key={i}
-            selected={option.label === this.state.selectedMetricLabel}
-            onClick={() => this.setState({ selectedMetricLabel: option.label })}
+            selected={option.value === this.state.selectedMetric}
+            onClick={() => this.setState({ selectedMetric: option.value, selectedMetricLabel: option.label })}
             lastItem={i === metricOptions.length - 1}
           >
             {option.label}
@@ -69,26 +406,50 @@ export default class ListSection extends Component<PropsType, StateType> {
     return (
       <StyledMetricsSection>
         <ParentSize>
-          {({ width, height }) => <AreaChart width={width} height={height} />}
+          {({ width, height }) => <AreaChart 
+            data={this.state.data} 
+            width={width} 
+            height={height} 
+            resolution={this.state.selectedRange}
+            margin={{ top: 60, right: -40, bottom: 0, left: 50 }}
+          />}
         </ParentSize>
         <MetricSelector
           onClick={() =>
             this.setState({ dropdownExpanded: !this.state.dropdownExpanded })
           }
         >
+          <MetricsLabel>
           {this.state.selectedMetricLabel}
+          </MetricsLabel>
           <i className="material-icons">arrow_drop_down</i>
           {this.renderDropdown()}
         </MetricSelector>
+        <ControllerSelector
+          onClick={() =>
+            this.setState({ controllerDropdownExpanded: !this.state.controllerDropdownExpanded })
+          }
+        >
+          <MetricsLabel>{this.state.selectedController?.metadata?.name}</MetricsLabel>
+          <i className="material-icons">arrow_drop_down</i>
+          {this.renderControllerDropdown()}
+        </ControllerSelector>
+        <PodSelector
+          onClick={() =>
+            this.setState({ podDropdownExpanded: !this.state.podDropdownExpanded })
+          }
+        >
+          <MetricsLabel>{this.state.selectedPod}</MetricsLabel>
+          <i className="material-icons">arrow_drop_down</i>
+          {this.renderPodDropdown()}
+        </PodSelector>
         <RangeWrapper>
           <TabSelector
             options={[
               { value: "1H", label: "1H" },
+              { value: "6H", label: "6H" },
               { value: "1D", label: "1D" },
               { value: "1M", label: "1M" },
-              { value: "3M", label: "3M" },
-              { value: "1Y", label: "1Y" },
-              { value: "ALL", label: "ALL" },
             ]}
             currentTab={this.state.selectedRange}
             setCurrentTab={(x: string) => this.setState({ selectedRange: x })}
@@ -99,7 +460,7 @@ export default class ListSection extends Component<PropsType, StateType> {
   }
 }
 
-ListSection.contextType = Context;
+MetricsSection.contextType = Context;
 
 const DropdownOverlay = styled.div`
   position: fixed;
@@ -151,21 +512,22 @@ const Dropdown = styled.div`
   box-shadow: 0 4px 8px 0px #00000088;
 `;
 
+
 const RangeWrapper = styled.div`
   position: absolute;
-  bottom: 10px;
+  top: 0;
+  right: 0;
   font-weight: bold;
-  left: 0;
-  width: 100%;
+  width: 156px;
 `;
 
 const MetricSelector = styled.div`
-  font-size: 16px;
+  font-size: 13px;
   font-weight: 500;
   color: #ffffff;
   position: absolute;
-  top: 0;
-  left: 5px;
+  top: 10px;
+  left: 0;
   display: flex;
   align-items: center;
   cursor: pointer;
@@ -183,6 +545,21 @@ const MetricSelector = styled.div`
   }
 `;
 
+const MetricsLabel = styled.div`
+white-space: nowrap;
+text-overflow: ellipsis;
+overflow: hidden;
+max-width: 200px;
+`
+
+const ControllerSelector = styled(MetricSelector)`
+  left: 230px;
+`
+
+const PodSelector = styled(MetricSelector)`
+  left: 490px;
+`
+
 const StyledMetricsSection = styled.div`
   width: 100%;
   height: 100%;

+ 2 - 2
dashboard/src/shared/Context.tsx

@@ -79,9 +79,9 @@ class ContextProvider extends Component {
         currentProject: null,
         projects: [],
         user: null,
-        devOpsMode: true,
+        devOpsMode: true
       });
-    },
+    }
   };
 
   render() {

+ 3 - 3
dashboard/src/shared/ansiparser.tsx

@@ -8,7 +8,7 @@ const foregroundColors = {
   "35": "magenta",
   "36": "cyan",
   "37": "white",
-  "90": "grey",
+  "90": "grey"
 } as Record<string, string>;
 
 const backgroundColors = {
@@ -19,13 +19,13 @@ const backgroundColors = {
   "44": "blue",
   "45": "magenta",
   "46": "cyan",
-  "47": "white",
+  "47": "white"
 } as Record<string, string>;
 
 const styles = {
   "1": "bold",
   "3": "italic",
-  "4": "underline",
+  "4": "underline"
 } as Record<string, string>;
 
 const eraseChar = (matchingText: any, result: any) => {

+ 82 - 58
dashboard/src/shared/api.tsx

@@ -18,7 +18,7 @@ const connectECRRegistry = baseApi<
     aws_integration_id: string;
   },
   { id: number }
->("POST", (pathParams) => {
+>("POST", pathParams => {
   return `/api/projects/${pathParams.id}/registries`;
 });
 
@@ -29,7 +29,7 @@ const connectGCRRegistry = baseApi<
     url: string;
   },
   { id: number }
->("POST", (pathParams) => {
+>("POST", pathParams => {
   return `/api/projects/${pathParams.id}/registries`;
 });
 
@@ -41,7 +41,7 @@ const createAWSIntegration = baseApi<
     aws_secret_access_key: string;
   },
   { id: number }
->("POST", (pathParams) => {
+>("POST", pathParams => {
   return `/api/projects/${pathParams.id}/integrations/aws`;
 });
 
@@ -54,7 +54,7 @@ const createDOCR = baseApi<
   {
     project_id: number;
   }
->("POST", (pathParams) => {
+>("POST", pathParams => {
   return `/api/projects/${pathParams.project_id}/provision/docr`;
 });
 
@@ -67,7 +67,7 @@ const createDOKS = baseApi<
   {
     project_id: number;
   }
->("POST", (pathParams) => {
+>("POST", pathParams => {
   return `/api/projects/${pathParams.project_id}/provision/doks`;
 });
 
@@ -80,7 +80,7 @@ const createGCPIntegration = baseApi<
   {
     project_id: number;
   }
->("POST", (pathParams) => {
+>("POST", pathParams => {
   return `/api/projects/${pathParams.project_id}/integrations/gcp`;
 });
 
@@ -91,7 +91,7 @@ const createGCR = baseApi<
   {
     project_id: number;
   }
->("POST", (pathParams) => {
+>("POST", pathParams => {
   return `/api/projects/${pathParams.project_id}/provision/gcr`;
 });
 
@@ -111,7 +111,7 @@ const createGHAction = baseApi<
     RELEASE_NAME: string;
     RELEASE_NAMESPACE: string;
   }
->("POST", (pathParams) => {
+>("POST", pathParams => {
   let { project_id, CLUSTER_ID, RELEASE_NAME, RELEASE_NAMESPACE } = pathParams;
   return `/api/projects/${project_id}/ci/actions?cluster_id=${CLUSTER_ID}&name=${RELEASE_NAME}&namespace=${RELEASE_NAMESPACE}`;
 });
@@ -124,7 +124,7 @@ const createGKE = baseApi<
   {
     project_id: number;
   }
->("POST", (pathParams) => {
+>("POST", pathParams => {
   return `/api/projects/${pathParams.project_id}/provision/gke`;
 });
 
@@ -135,11 +135,11 @@ const createInvite = baseApi<
   {
     id: number;
   }
->("POST", (pathParams) => {
+>("POST", pathParams => {
   return `/api/projects/${pathParams.id}/invites`;
 });
 
-const createProject = baseApi<{ name: string }, {}>("POST", (pathParams) => {
+const createProject = baseApi<{ name: string }, {}>("POST", pathParams => {
   return `/api/projects`;
 });
 
@@ -149,18 +149,18 @@ const deleteCluster = baseApi<
     project_id: number;
     cluster_id: number;
   }
->("DELETE", (pathParams) => {
+>("DELETE", pathParams => {
   return `/api/projects/${pathParams.project_id}/clusters/${pathParams.cluster_id}`;
 });
 
 const deleteInvite = baseApi<{}, { id: number; invId: number }>(
   "DELETE",
-  (pathParams) => {
+  pathParams => {
     return `/api/projects/${pathParams.id}/invites/${pathParams.invId}`;
   }
 );
 
-const deleteProject = baseApi<{}, { id: number }>("DELETE", (pathParams) => {
+const deleteProject = baseApi<{}, { id: number }>("DELETE", pathParams => {
   return `/api/projects/${pathParams.id}`;
 });
 
@@ -179,7 +179,7 @@ const deployTemplate = baseApi<
     name: string;
     version: string;
   }
->("POST", (pathParams) => {
+>("POST", pathParams => {
   let { cluster_id, id, name, version } = pathParams;
   return `/api/projects/${id}/deploy/${name}/${version}?cluster_id=${cluster_id}`;
 });
@@ -192,7 +192,7 @@ const destroyCluster = baseApi<
     project_id: number;
     infra_id: number;
   }
->("POST", (pathParams) => {
+>("POST", pathParams => {
   return `/api/projects/${pathParams.project_id}/infra/${pathParams.infra_id}/eks/destroy`;
 });
 
@@ -208,7 +208,7 @@ const getBranchContents = baseApi<
     name: string;
     branch: string;
   }
->("GET", (pathParams) => {
+>("GET", pathParams => {
   return `/api/projects/${pathParams.project_id}/gitrepos/${pathParams.git_repo_id}/repos/${pathParams.kind}/${pathParams.owner}/${pathParams.name}/${pathParams.branch}/contents`;
 });
 
@@ -221,7 +221,7 @@ const getBranches = baseApi<
     owner: string;
     name: string;
   }
->("GET", (pathParams) => {
+>("GET", pathParams => {
   return `/api/projects/${pathParams.project_id}/gitrepos/${pathParams.git_repo_id}/repos/${pathParams.kind}/${pathParams.owner}/${pathParams.name}/branches`;
 });
 
@@ -232,7 +232,7 @@ const getChart = baseApi<
     storage: StorageType;
   },
   { id: number; name: string; revision: number }
->("GET", (pathParams) => {
+>("GET", pathParams => {
   return `/api/projects/${pathParams.id}/releases/${pathParams.name}/${pathParams.revision}`;
 });
 
@@ -247,7 +247,7 @@ const getCharts = baseApi<
     statusFilter: string[];
   },
   { id: number }
->("GET", (pathParams) => {
+>("GET", pathParams => {
   return `/api/projects/${pathParams.id}/releases`;
 });
 
@@ -258,7 +258,7 @@ const getChartComponents = baseApi<
     storage: StorageType;
   },
   { id: number; name: string; revision: number }
->("GET", (pathParams) => {
+>("GET", pathParams => {
   return `/api/projects/${pathParams.id}/releases/${pathParams.name}/${pathParams.revision}/components`;
 });
 
@@ -269,13 +269,13 @@ const getChartControllers = baseApi<
     storage: StorageType;
   },
   { id: number; name: string; revision: number }
->("GET", (pathParams) => {
+>("GET", pathParams => {
   return `/api/projects/${pathParams.id}/releases/${pathParams.name}/${pathParams.revision}/controllers`;
 });
 
 const getClusterIntegrations = baseApi("GET", "/api/integrations/cluster");
 
-const getClusters = baseApi<{}, { id: number }>("GET", (pathParams) => {
+const getClusters = baseApi<{}, { id: number }>("GET", pathParams => {
   return `/api/projects/${pathParams.id}/clusters`;
 });
 
@@ -285,7 +285,7 @@ const getGitRepoList = baseApi<
     project_id: number;
     git_repo_id: number;
   }
->("GET", (pathParams) => {
+>("GET", pathParams => {
   return `/api/projects/${pathParams.project_id}/gitrepos/${pathParams.git_repo_id}/repos`;
 });
 
@@ -294,7 +294,7 @@ const getGitRepos = baseApi<
   {
     project_id: number;
   }
->("GET", (pathParams) => {
+>("GET", pathParams => {
   return `/api/projects/${pathParams.project_id}/gitrepos`;
 });
 
@@ -304,7 +304,7 @@ const getImageRepos = baseApi<
     project_id: number;
     registry_id: number;
   }
->("GET", (pathParams) => {
+>("GET", pathParams => {
   return `/api/projects/${pathParams.project_id}/registries/${pathParams.registry_id}/repositories`;
 });
 
@@ -315,7 +315,7 @@ const getImageTags = baseApi<
     registry_id: number;
     repo_name: string;
   }
->("GET", (pathParams) => {
+>("GET", pathParams => {
   return `/api/projects/${pathParams.project_id}/registries/${pathParams.registry_id}/repositories/${pathParams.repo_name}`;
 });
 
@@ -324,7 +324,7 @@ const getInfra = baseApi<
   {
     project_id: number;
   }
->("GET", (pathParams) => {
+>("GET", pathParams => {
   return `/api/projects/${pathParams.project_id}/infra`;
 });
 
@@ -333,11 +333,11 @@ const getIngress = baseApi<
     cluster_id: number;
   },
   { name: string; namespace: string; id: number }
->("GET", (pathParams) => {
+>("GET", pathParams => {
   return `/api/projects/${pathParams.id}/k8s/${pathParams.namespace}/ingress/${pathParams.name}`;
 });
 
-const getInvites = baseApi<{}, { id: number }>("GET", (pathParams) => {
+const getInvites = baseApi<{}, { id: number }>("GET", pathParams => {
   return `/api/projects/${pathParams.id}/invites`;
 });
 
@@ -347,16 +347,32 @@ const getMatchingPods = baseApi<
     selectors: string[];
   },
   { id: number }
->("GET", (pathParams) => {
+>("GET", pathParams => {
   return `/api/projects/${pathParams.id}/k8s/pods`;
 });
 
+const getMetrics = baseApi<
+  {
+    cluster_id: number;
+    metric: string;
+    shouldsum: boolean;
+    pods: string[];
+    namespace: string;
+    startrange: number;
+    endrange: number;
+    resolution: string;
+  },
+  { id: number }
+>("GET", pathParams => {
+  return `/api/projects/${pathParams.id}/k8s/metrics`;
+});
+
 const getNamespaces = baseApi<
   {
     cluster_id: number;
   },
   { id: number }
->("GET", (pathParams) => {
+>("GET", pathParams => {
   return `/api/projects/${pathParams.id}/k8s/namespaces`;
 });
 
@@ -365,29 +381,35 @@ const getOAuthIds = baseApi<
   {
     project_id: number;
   }
->("GET", (pathParams) => {
+>("GET", pathParams => {
   return `/api/projects/${pathParams.project_id}/integrations/oauth`;
 });
 
-const getProjectClusters = baseApi<{}, { id: number }>("GET", (pathParams) => {
+const getProjectClusters = baseApi<{}, { id: number }>("GET", pathParams => {
   return `/api/projects/${pathParams.id}/clusters`;
 });
 
-const getProjectRegistries = baseApi<{}, { id: number }>(
-  "GET",
-  (pathParams) => {
-    return `/api/projects/${pathParams.id}/registries`;
-  }
-);
+const getProjectRegistries = baseApi<{}, { id: number }>("GET", pathParams => {
+  return `/api/projects/${pathParams.id}/registries`;
+});
 
-const getProjectRepos = baseApi<{}, { id: number }>("GET", (pathParams) => {
+const getProjectRepos = baseApi<{}, { id: number }>("GET", pathParams => {
   return `/api/projects/${pathParams.id}/repos`;
 });
 
-const getProjects = baseApi<{}, { id: number }>("GET", (pathParams) => {
+const getProjects = baseApi<{}, { id: number }>("GET", pathParams => {
   return `/api/users/${pathParams.id}/projects`;
 });
 
+const getPrometheusIsInstalled = baseApi<
+  {
+    cluster_id: number;
+  },
+  { id: number }
+>("GET", pathParams => {
+  return `/api/projects/${pathParams.id}/k8s/prometheus/detect`;
+});
+
 const getRegistryIntegrations = baseApi("GET", "/api/integrations/registry");
 
 const getReleaseToken = baseApi<
@@ -397,7 +419,7 @@ const getReleaseToken = baseApi<
     storage: StorageType;
   },
   { name: string; id: number }
->("GET", (pathParams) => {
+>("GET", pathParams => {
   return `/api/projects/${pathParams.id}/releases/${pathParams.name}/webhook_token`;
 });
 
@@ -409,7 +431,7 @@ const destroyEKS = baseApi<
     project_id: number;
     infra_id: number;
   }
->("POST", (pathParams) => {
+>("POST", pathParams => {
   return `/api/projects/${pathParams.project_id}/infra/${pathParams.infra_id}/eks/destroy`;
 });
 
@@ -421,7 +443,7 @@ const destroyGKE = baseApi<
     project_id: number;
     infra_id: number;
   }
->("POST", (pathParams) => {
+>("POST", pathParams => {
   return `/api/projects/${pathParams.project_id}/infra/${pathParams.infra_id}/gke/destroy`;
 });
 
@@ -433,13 +455,13 @@ const destroyDOKS = baseApi<
     project_id: number;
     infra_id: number;
   }
->("POST", (pathParams) => {
+>("POST", pathParams => {
   return `/api/projects/${pathParams.project_id}/infra/${pathParams.infra_id}/doks/destroy`;
 });
 
 const getRepoIntegrations = baseApi("GET", "/api/integrations/repo");
 
-const getRepos = baseApi<{}, { id: number }>("GET", (pathParams) => {
+const getRepos = baseApi<{}, { id: number }>("GET", pathParams => {
   return `/api/projects/${pathParams.id}/repos`;
 });
 
@@ -450,20 +472,20 @@ const getRevisions = baseApi<
     storage: StorageType;
   },
   { id: number; name: string }
->("GET", (pathParams) => {
+>("GET", pathParams => {
   return `/api/projects/${pathParams.id}/releases/${pathParams.name}/history`;
 });
 
 const getTemplateInfo = baseApi<{}, { name: string; version: string }>(
   "GET",
-  (pathParams) => {
+  pathParams => {
     return `/api/templates/${pathParams.name}/${pathParams.version}`;
   }
 );
 
 const getTemplates = baseApi("GET", "/api/templates");
 
-const getUser = baseApi<{}, { id: number }>("GET", (pathParams) => {
+const getUser = baseApi<{}, { id: number }>("GET", pathParams => {
   return `/api/users/${pathParams.id}`;
 });
 
@@ -472,7 +494,7 @@ const linkGithubProject = baseApi<
   {
     project_id: number;
   }
->("GET", (pathParams) => {
+>("GET", pathParams => {
   return `/api/oauth/projects/${pathParams.project_id}/github`;
 });
 
@@ -489,7 +511,7 @@ const provisionECR = baseApi<
     aws_integration_id: string;
   },
   { id: number }
->("POST", (pathParams) => {
+>("POST", pathParams => {
   return `/api/projects/${pathParams.id}/provision/ecr`;
 });
 
@@ -499,7 +521,7 @@ const provisionEKS = baseApi<
     aws_integration_id: string;
   },
   { id: number }
->("POST", (pathParams) => {
+>("POST", pathParams => {
   return `/api/projects/${pathParams.id}/provision/eks`;
 });
 
@@ -519,7 +541,7 @@ const rollbackChart = baseApi<
     name: string;
     cluster_id: number;
   }
->("POST", (pathParams) => {
+>("POST", pathParams => {
   let { id, name, cluster_id } = pathParams;
   return `/api/projects/${id}/releases/${name}/rollback?cluster_id=${cluster_id}`;
 });
@@ -533,7 +555,7 @@ const uninstallTemplate = baseApi<
     namespace: string;
     storage: StorageType;
   }
->("POST", (pathParams) => {
+>("POST", pathParams => {
   let { id, name, cluster_id, storage, namespace } = pathParams;
   return `/api/projects/${id}/deploy/${name}?cluster_id=${cluster_id}&namespace=${namespace}&storage=${storage}`;
 });
@@ -544,7 +566,7 @@ const updateUser = baseApi<
     allowedContexts?: string[];
   },
   { id: number }
->("PUT", (pathParams) => {
+>("PUT", pathParams => {
   return `/api/users/${pathParams.id}`;
 });
 
@@ -559,7 +581,7 @@ const upgradeChartValues = baseApi<
     name: string;
     cluster_id: number;
   }
->("POST", (pathParams) => {
+>("POST", pathParams => {
   let { id, name, cluster_id } = pathParams;
   return `/api/projects/${id}/releases/${name}/upgrade?cluster_id=${cluster_id}`;
 });
@@ -601,12 +623,14 @@ export default {
   getIngress,
   getInvites,
   getMatchingPods,
+  getMetrics,
   getNamespaces,
   getOAuthIds,
   getProjectClusters,
   getProjectRegistries,
   getProjectRepos,
   getProjects,
+  getPrometheusIsInstalled,
   getRegistryIntegrations,
   getReleaseToken,
   getRepoIntegrations,
@@ -624,5 +648,5 @@ export default {
   rollbackChart,
   uninstallTemplate,
   updateUser,
-  upgradeChartValues,
+  upgradeChartValues
 };

+ 6 - 6
dashboard/src/shared/baseApi.tsx

@@ -21,23 +21,23 @@ export const baseApi = <T extends {}, S = {}>(
     if (requestType === "POST") {
       return axios.post(endpointString, params, {
         headers: {
-          Authorization: `Bearer ${token}`,
-        },
+          Authorization: `Bearer ${token}`
+        }
       });
     } else if (requestType === "PUT") {
       return axios.put(endpointString, params, {
         headers: {
-          Authorization: `Bearer ${token}`,
-        },
+          Authorization: `Bearer ${token}`
+        }
       });
     } else if (requestType === "DELETE") {
       return axios.delete(endpointString, params);
     } else {
       return axios.get(endpointString, {
         params,
-        paramsSerializer: function (params) {
+        paramsSerializer: function(params) {
           return qs.stringify(params, { arrayFormat: "repeat" });
-        },
+        }
       });
     }
   };

+ 17 - 17
dashboard/src/shared/common.tsx

@@ -10,7 +10,7 @@ export const infraNames: any = {
   gcr: "Google Container Registry (GCR)",
   gke: "Google Kubernetes Engine (GKE)",
   docr: "Digital Ocean Container Registry",
-  doks: "Digital Ocean Kubernetes Service",
+  doks: "Digital Ocean Kubernetes Service"
 };
 
 export const integrationList: any = {
@@ -18,68 +18,68 @@ export const integrationList: any = {
     icon:
       "https://uxwing.com/wp-content/themes/uxwing/download/10-brands-and-social-media/kubernetes.png",
     label: "Kubernetes",
-    buttonText: "Add a Cluster",
+    buttonText: "Add a Cluster"
   },
   repo: {
     icon:
       "https://3.bp.blogspot.com/-xhNpNJJyQhk/XIe4GY78RQI/AAAAAAAAItc/ouueFUj2Hqo5dntmnKqEaBJR4KQ4Q2K3ACK4BGAYYCw/s1600/logo%2Bgit%2Bicon.png",
     label: "Git Repository",
-    buttonText: "Link a Github Account",
+    buttonText: "Link a Github Account"
   },
   registry: {
     icon:
       "https://cdn4.iconfinder.com/data/icons/logos-and-brands/512/97_Docker_logo_logos-512.png",
     label: "Docker Registry",
-    buttonText: "Add a Registry",
+    buttonText: "Add a Registry"
   },
   gke: {
     icon: "https://sysdig.com/wp-content/uploads/2016/08/GKE_color.png",
-    label: "Google Kubernetes Engine (GKE)",
+    label: "Google Kubernetes Engine (GKE)"
   },
   eks: {
     icon: "https://img.stackshare.io/service/7991/amazon-eks.png",
-    label: "Amazon Elastic Kubernetes Service (EKS)",
+    label: "Amazon Elastic Kubernetes Service (EKS)"
   },
   kube: {
     icon:
       "https://uxwing.com/wp-content/themes/uxwing/download/10-brands-and-social-media/kubernetes.png",
-    label: "Upload Kubeconfig",
+    label: "Upload Kubeconfig"
   },
   docker: {
     icon:
       "https://cdn4.iconfinder.com/data/icons/logos-and-brands/512/97_Docker_logo_logos-512.png",
-    label: "Docker Hub",
+    label: "Docker Hub"
   },
   gcr: {
     icon:
       "https://carlossanchez.files.wordpress.com/2019/06/21046548.png?w=640",
-    label: "Google Container Registry (GCR)",
+    label: "Google Container Registry (GCR)"
   },
   ecr: {
     icon:
       "https://avatars2.githubusercontent.com/u/52505464?s=400&u=da920f994c67665c7ad6c606a5286557d4f8555f&v=4",
-    label: "Elastic Container Registry (ECR)",
+    label: "Elastic Container Registry (ECR)"
   },
   aws: {
     icon: aws,
-    label: "AWS",
+    label: "AWS"
   },
   gcp: {
     icon: gcp,
-    label: "GCP",
+    label: "GCP"
   },
   do: {
     icon: digitalOcean,
-    label: "DigitalOcean",
+    label: "DigitalOcean"
   },
   github: {
     icon: github,
-    label: "GitHub",
+    label: "GitHub"
   },
   gitlab: {
     icon: "https://about.gitlab.com/images/press/logo/png/gitlab-icon-rgb.png",
-    label: "Gitlab",
-  },
+    label: "Gitlab"
+  }
 };
 
 export const isAlphanumeric = (x: string | null) => {
@@ -92,6 +92,6 @@ export const isAlphanumeric = (x: string | null) => {
 
 export const getIgnoreCase = (object: any, key: string) => {
   return object[
-    Object.keys(object).find((k) => k.toLowerCase() === key.toLowerCase())
+    Object.keys(object).find(k => k.toLowerCase() === key.toLowerCase())
   ];
 };

+ 5 - 5
dashboard/src/shared/feedback.tsx

@@ -10,18 +10,18 @@ export const handleSubmitFeedback = (
       {
         key: process.env.DISCORD_KEY,
         cid: process.env.DISCORD_CID,
-        message: msg,
+        message: msg
       },
       {
         headers: {
-          Authorization: `Bearer <>`,
-        },
+          Authorization: `Bearer <>`
+        }
       }
     )
-    .then((res) => {
+    .then(res => {
       callback && callback(null, res);
     })
-    .catch((err) => {
+    .catch(err => {
       callback && callback(err, null);
     });
 };

+ 2 - 2
dashboard/src/shared/rosettaStone.tsx

@@ -11,11 +11,11 @@ export const kindToIcon: { [kind: string]: string } = {
   Role: "portrait",
   RoleBinding: "swap_horizontal_circle",
   ConfigMap: "map",
-  PodSecurityPolicy: "security",
+  PodSecurityPolicy: "security"
 };
 
 export const edgeColors: { [kind: string]: string } = {
   LabelRel: "#32a85f",
   ControlRel: "#fcb603",
-  SpecRel: "#949EFF",
+  SpecRel: "#949EFF"
 };

+ 2 - 2
dashboard/src/shared/routing.tsx

@@ -14,7 +14,7 @@ export const PorterUrls = [
   "integrations",
   "new-project",
   "cluster-dashboard",
-  "project-settings",
+  "project-settings"
 ];
 
 export const setSearchParam = (
@@ -26,6 +26,6 @@ export const setSearchParam = (
   urlParams.set(key, value);
   return {
     pathname: location.pathname,
-    search: urlParams.toString(),
+    search: urlParams.toString()
   };
 };

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

@@ -66,7 +66,7 @@ export interface EdgeType {
 export enum StorageType {
   Secret = "secret",
   ConfigMap = "configmap",
-  Memory = "memory",
+  Memory = "memory"
 }
 
 // PorterTemplate represents a bundled Porter template

+ 6 - 0
internal/kubernetes/prometheus/metrics.go

@@ -55,6 +55,9 @@ func QueryPrometheus(
 		query = fmt.Sprintf("rate(container_cpu_usage_seconds_total{%s}[5m])", podSelector)
 	} 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, strings.Join(opts.PodList, "|"))
+		query = fmt.Sprintf("rate(container_network_receive_bytes_total{%s}[5m])", netPodSelector)
 	}
 
 	if opts.ShouldSum {
@@ -101,6 +104,7 @@ type promParsedSingletonQueryResult struct {
 	Date   interface{} `json:"date,omitempty"`
 	CPU    interface{} `json:"cpu,omitempty"`
 	Memory interface{} `json:"memory,omitempty"`
+	Bytes  interface{} `json:"bytes,omitempty"`
 }
 
 type promParsedSingletonQuery struct {
@@ -131,6 +135,8 @@ func parseQuery(rawQuery []byte, metric string) ([]byte, error) {
 				singletonResult.CPU = values[1]
 			} else if metric == "memory" {
 				singletonResult.Memory = values[1]
+			} else if metric == "network" {
+				singletonResult.Bytes = values[1]
 			}
 
 			singletonResults = append(singletonResults, *singletonResult)

+ 46 - 0
server/api/k8s_handler.go

@@ -325,6 +325,52 @@ func (app *App) HandleStreamControllerStatus(w http.ResponseWriter, r *http.Requ
 	}
 }
 
+// HandleDetectPrometheusInstalled detects a prometheus installation in the target cluster
+func (app *App) HandleDetectPrometheusInstalled(w http.ResponseWriter, r *http.Request) {
+	vals, err := url.ParseQuery(r.URL.RawQuery)
+
+	if err != nil {
+		app.handleErrorFormDecoding(err, ErrReleaseDecode, w)
+		return
+	}
+
+	// get the filter options
+	form := &forms.K8sForm{
+		OutOfClusterConfig: &kubernetes.OutOfClusterConfig{
+			Repo:              app.Repo,
+			DigitalOceanOAuth: app.DOConf,
+		},
+	}
+
+	form.PopulateK8sOptionsFromQueryParams(vals, app.Repo.Cluster)
+
+	// validate the form
+	if err := app.validator.Struct(form); err != nil {
+		app.handleErrorFormValidation(err, ErrK8sValidate, w)
+		return
+	}
+
+	// create a new agent
+	var agent *kubernetes.Agent
+
+	if app.ServerConf.IsTesting {
+		agent = app.TestAgents.K8sAgent
+	} else {
+		agent, err = kubernetes.GetAgentOutOfClusterConfig(form.OutOfClusterConfig)
+	}
+
+	// detect prometheus service
+	_, found, err := prometheus.GetPrometheusService(agent.Clientset)
+
+	if !found {
+		http.NotFound(w, r)
+		return
+	}
+
+	w.WriteHeader(http.StatusOK)
+	return
+}
+
 func (app *App) HandleGetPodMetrics(w http.ResponseWriter, r *http.Request) {
 	vals, err := url.ParseQuery(r.URL.RawQuery)
 

+ 14 - 2
server/api/release_handler.go

@@ -8,6 +8,7 @@ import (
 	"strconv"
 	"strings"
 
+	"github.com/porter-dev/porter/internal/kubernetes/prometheus"
 	"github.com/porter-dev/porter/internal/models"
 	"github.com/porter-dev/porter/internal/templater/parser"
 	"helm.sh/helm/v3/pkg/release"
@@ -70,7 +71,8 @@ func (app *App) HandleListReleases(w http.ResponseWriter, r *http.Request) {
 // PorterRelease is a helm release with a form attached
 type PorterRelease struct {
 	*release.Release
-	Form *models.FormYAML `json:"form"`
+	Form       *models.FormYAML `json:"form"`
+	HasMetrics bool             `json:"has_metrics"`
 }
 
 // HandleGetRelease retrieves a single release based on a name and revision
@@ -149,7 +151,7 @@ func (app *App) HandleGetRelease(w http.ResponseWriter, r *http.Request) {
 		HelmRelease:   release,
 	}
 
-	res := &PorterRelease{release, nil}
+	res := &PorterRelease{release, nil, false}
 
 	for _, file := range release.Chart.Files {
 		if strings.Contains(file.Name, "form.yaml") {
@@ -176,6 +178,16 @@ func (app *App) HandleGetRelease(w http.ResponseWriter, r *http.Request) {
 		}
 	}
 
+	// get prometheus service
+	_, found, err := prometheus.GetPrometheusService(agent.K8sAgent.Clientset)
+
+	if err != nil {
+		app.handleErrorFormValidation(err, ErrK8sValidate, w)
+		return
+	}
+
+	res.HasMetrics = found
+
 	if err := json.NewEncoder(w).Encode(res); err != nil {
 		app.handleErrorFormDecoding(err, ErrReleaseDecode, w)
 		return

+ 14 - 0
server/router/router.go

@@ -1050,6 +1050,20 @@ func New(a *api.App) *chi.Mux {
 			),
 		)
 
+		r.Method(
+			"GET",
+			"/projects/{project_id}/k8s/prometheus/detect",
+			auth.DoesUserHaveProjectAccess(
+				auth.DoesUserHaveClusterAccess(
+					requestlog.NewHandler(a.HandleDetectPrometheusInstalled, l),
+					mw.URLParam,
+					mw.QueryParam,
+				),
+				mw.URLParam,
+				mw.ReadAccess,
+			),
+		)
+
 		r.Method(
 			"GET",
 			"/projects/{project_id}/k8s/metrics",

Некоторые файлы не были показаны из-за большого количества измененных файлов