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

[POR-1929] add helm overrides tab (feature flagged) and latest values tab (for porter operators) (#3792)

Co-authored-by: David Townley <davidtownley@Davids-MacBook-Air.local>
d-g-town 2 лет назад
Родитель
Сommit
7e1201ba15

+ 133 - 0
api/server/handlers/porter_app/helm_values_v2.go

@@ -0,0 +1,133 @@
+package porter_app
+
+import (
+	"encoding/base64"
+	"net/http"
+
+	"github.com/porter-dev/porter/api/server/authz"
+	"github.com/porter-dev/porter/api/server/shared/requestutils"
+
+	"connectrpc.com/connect"
+
+	porterv1 "github.com/porter-dev/api-contracts/generated/go/porter/v1"
+
+	"github.com/google/uuid"
+
+	"github.com/porter-dev/porter/internal/telemetry"
+
+	"github.com/porter-dev/porter/api/server/handlers"
+	"github.com/porter-dev/porter/api/server/shared"
+	"github.com/porter-dev/porter/api/server/shared/apierrors"
+	"github.com/porter-dev/porter/api/server/shared/config"
+	"github.com/porter-dev/porter/api/types"
+	"github.com/porter-dev/porter/internal/models"
+)
+
+// AppHelmValuesHandler handles requests to the /apps/{porter_app_name}/helm-values endpoint
+type AppHelmValuesHandler struct {
+	handlers.PorterHandlerReadWriter
+	authz.KubernetesAgentGetter
+}
+
+// NewAppHelmValuesHandler returns a new AppHelmValuesHandler
+func NewAppHelmValuesHandler(
+	config *config.Config,
+	decoderValidator shared.RequestDecoderValidator,
+	writer shared.ResultWriter,
+) *AppHelmValuesHandler {
+	return &AppHelmValuesHandler{
+		PorterHandlerReadWriter: handlers.NewDefaultPorterHandler(config, decoderValidator, writer),
+		KubernetesAgentGetter:   authz.NewOutOfClusterAgentGetter(config),
+	}
+}
+
+// AppHelmValuesRequest is the request object for the /apps/{porter_app_name}/helm-values endpoint
+type AppHelmValuesRequest struct {
+	AppID              uint   `schema:"app_id"`
+	DeploymentTargetID string `schema:"deployment_target_id"`
+	WithDefaults       bool   `schema:"with_defaults"`
+}
+
+// AppHelmValuesResponse is the response object for the /apps/{porter_app_name}/helm-values endpoint
+type AppHelmValuesResponse struct {
+	// AppRevision is the latest revision for the app
+	HelmValues string `json:"helm_values"`
+}
+
+// ServeHTTP translates the request into a helmValues grpc request, forwards to the cluster control plane, and returns the response.
+func (c *AppHelmValuesHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	ctx, span := telemetry.NewSpan(r.Context(), "serve-app-helm-values")
+	defer span.End()
+
+	project, _ := ctx.Value(types.ProjectScope).(*models.Project)
+	cluster, _ := ctx.Value(types.ClusterScope).(*models.Cluster)
+
+	telemetry.WithAttributes(span,
+		telemetry.AttributeKV{Key: "project-id", Value: project.ID},
+		telemetry.AttributeKV{Key: "cluster-id", Value: cluster.ID},
+	)
+
+	appName, reqErr := requestutils.GetURLParamString(r, types.URLParamPorterAppName)
+	if reqErr != nil {
+		e := telemetry.Error(ctx, span, reqErr, "error parsing app name from url")
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(e, http.StatusBadRequest))
+		return
+	}
+
+	telemetry.WithAttributes(span, telemetry.AttributeKV{Key: "app-name", Value: appName})
+
+	request := &AppHelmValuesRequest{}
+	if ok := c.DecodeAndValidate(w, r, request); !ok {
+		err := telemetry.Error(ctx, span, nil, "error decoding request")
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusBadRequest))
+		return
+	}
+
+	_, err := uuid.Parse(request.DeploymentTargetID)
+	if err != nil {
+		err := telemetry.Error(ctx, span, err, "error parsing deployment target id")
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusBadRequest))
+		return
+	}
+	telemetry.WithAttributes(span, telemetry.AttributeKV{Key: "deployment-target-id", Value: request.DeploymentTargetID})
+
+	if request.AppID == 0 {
+		err := telemetry.Error(ctx, span, nil, "app id is required")
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusBadRequest))
+		return
+	}
+	telemetry.WithAttributes(span, telemetry.AttributeKV{Key: "app-id", Value: request.AppID})
+
+	helmValuesReq := connect.NewRequest(&porterv1.AppHelmValuesRequest{
+		ProjectId:          int64(project.ID),
+		AppId:              int64(request.AppID),
+		DeploymentTargetId: request.DeploymentTargetID,
+		WithDefaults:       request.WithDefaults,
+	})
+
+	helmValuesResp, err := c.Config().ClusterControlPlaneClient.AppHelmValues(ctx, helmValuesReq)
+	if err != nil {
+		err := telemetry.Error(ctx, span, err, "error getting app helm values from cluster control plane client")
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusBadRequest))
+		return
+	}
+
+	if helmValuesResp == nil || helmValuesResp.Msg == nil {
+		err := telemetry.Error(ctx, span, err, "app helm values resp is nil")
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
+		return
+	}
+
+	decodedValues, err := base64.StdEncoding.DecodeString(helmValuesResp.Msg.B64Values)
+	if err != nil {
+		err := telemetry.Error(ctx, span, err, "error decoding helm values")
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
+		return
+	}
+
+	response := AppHelmValuesResponse{
+		HelmValues: string(decodedValues),
+	}
+
+	c.WriteResult(w, r, response)
+}

+ 29 - 0
api/server/router/porter_app.go

@@ -1357,5 +1357,34 @@ func getPorterAppRoutes(
 		Router:   r,
 	})
 
+	// GET /api/projects/{project_id}/clusters/{cluster_id}/apps/{porter_app_name}/helm-values -> porter_app.NewAppHelmValuesHandler
+	appHelmValuesEndpoint := factory.NewAPIEndpoint(
+		&types.APIRequestMetadata{
+			Verb:   types.APIVerbGet,
+			Method: types.HTTPVerbGet,
+			Path: &types.Path{
+				Parent:       basePath,
+				RelativePath: fmt.Sprintf("%s/{%s}/helm-values", relPathV2, types.URLParamPorterAppName),
+			},
+			Scopes: []types.PermissionScope{
+				types.UserScope,
+				types.ProjectScope,
+				types.ClusterScope,
+			},
+		},
+	)
+
+	appHelmValuesHandler := porter_app.NewAppHelmValuesHandler(
+		config,
+		factory.GetDecoderValidator(),
+		factory.GetResultWriter(),
+	)
+
+	routes = append(routes, &router.Route{
+		Endpoint: appHelmValuesEndpoint,
+		Handler:  appHelmValuesHandler,
+		Router:   r,
+	})
+
 	return routes, newPath
 }

+ 7 - 7
dashboard/package-lock.json

@@ -13,7 +13,7 @@
         "@loadable/component": "^5.15.2",
         "@material-ui/core": "^4.11.3",
         "@material-ui/lab": "^4.0.0-alpha.61",
-        "@porter-dev/api-contracts": "^0.2.14",
+        "@porter-dev/api-contracts": "^0.2.15",
         "@react-spring/web": "^9.6.1",
         "@sentry/react": "^6.13.2",
         "@sentry/tracing": "^6.13.2",
@@ -2455,9 +2455,9 @@
       }
     },
     "node_modules/@porter-dev/api-contracts": {
-      "version": "0.2.14",
-      "resolved": "https://registry.npmjs.org/@porter-dev/api-contracts/-/api-contracts-0.2.14.tgz",
-      "integrity": "sha512-P1OrwtrXiuL0bJ90BYJXE682TTdfaTcrqJNir9kY5HHPcAqJLDRE69awDsta2aBf0YZ89sB+vHYcduAWyY5luA==",
+      "version": "0.2.15",
+      "resolved": "https://registry.npmjs.org/@porter-dev/api-contracts/-/api-contracts-0.2.15.tgz",
+      "integrity": "sha512-2TmCMyjPbdHZcTV6ThueV0ITM4d/nipjNUJk0vbNtUeeJ8WwKebxBwbFaTfhTVJ4GLmnsa08ZAbJ372Nizc0Qw==",
       "dependencies": {
         "@bufbuild/protobuf": "^1.1.0"
       }
@@ -16956,9 +16956,9 @@
       "integrity": "sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A=="
     },
     "@porter-dev/api-contracts": {
-      "version": "0.2.14",
-      "resolved": "https://registry.npmjs.org/@porter-dev/api-contracts/-/api-contracts-0.2.14.tgz",
-      "integrity": "sha512-P1OrwtrXiuL0bJ90BYJXE682TTdfaTcrqJNir9kY5HHPcAqJLDRE69awDsta2aBf0YZ89sB+vHYcduAWyY5luA==",
+      "version": "0.2.15",
+      "resolved": "https://registry.npmjs.org/@porter-dev/api-contracts/-/api-contracts-0.2.15.tgz",
+      "integrity": "sha512-2TmCMyjPbdHZcTV6ThueV0ITM4d/nipjNUJk0vbNtUeeJ8WwKebxBwbFaTfhTVJ4GLmnsa08ZAbJ372Nizc0Qw==",
       "requires": {
         "@bufbuild/protobuf": "^1.1.0"
       }

+ 1 - 1
dashboard/package.json

@@ -8,7 +8,7 @@
     "@loadable/component": "^5.15.2",
     "@material-ui/core": "^4.11.3",
     "@material-ui/lab": "^4.0.0-alpha.61",
-    "@porter-dev/api-contracts": "^0.2.14",
+    "@porter-dev/api-contracts": "^0.2.15",
     "@react-spring/web": "^9.6.1",
     "@sentry/react": "^6.13.2",
     "@sentry/tracing": "^6.13.2",

+ 9 - 2
dashboard/src/lib/porter-apps/index.ts

@@ -9,7 +9,7 @@ import {
   serviceProto,
   serviceValidator,
 } from "./services";
-import { Build, PorterApp, Service } from "@porter-dev/api-contracts";
+import {Build, HelmOverrides, PorterApp, Service} from "@porter-dev/api-contracts";
 import { match } from "ts-pattern";
 import { KeyValueType } from "main/home/cluster-dashboard/env-groups/EnvGroupArray";
 import { BuildOptions, buildValidator } from "./build";
@@ -84,6 +84,7 @@ export const clientAppValidator = z.object({
     .array()
     .default([]),
   build: buildValidator,
+  helmOverrides: z.string().optional(),
 });
 export type ClientPorterApp = z.infer<typeof clientAppValidator>;
 
@@ -235,7 +236,8 @@ export function clientAppToProto(data: PorterAppFormData): PorterApp {
           })),
           build: clientBuildToProto(app.build),
           ...(predeploy && {
-            predeploy: serviceProto(serializeService(predeploy)),
+          predeploy: serviceProto(serializeService(predeploy)),
+          helmOverrides: app.helmOverrides != null ? new HelmOverrides({ b64Values: btoa(app.helmOverrides)}) : undefined,
           }),
         })
     )
@@ -253,6 +255,7 @@ export function clientAppToProto(data: PorterAppFormData): PorterApp {
             repository: src.image.repository,
             tag: src.image.tag,
           },
+          helmOverrides: app.helmOverrides != null ? new HelmOverrides({ b64Values: btoa(app.helmOverrides)}) : undefined,
         })
     )
     .exhaustive();
@@ -355,6 +358,8 @@ export function clientAppFromProto({
     })),
   ];
 
+  const helmOverrides = proto.helmOverrides == null ? "" : atob(proto.helmOverrides.b64Values);
+
   if (proto.predeploy) {
     predeployList.push(
       deserializeService({
@@ -385,6 +390,7 @@ export function clientAppFromProto({
         buildpacks: [],
         builder: "",
       },
+      helmOverrides: helmOverrides,
     };
   }
 
@@ -420,6 +426,7 @@ export function clientAppFromProto({
       buildpacks: [],
       builder: "",
     },
+    helmOverrides: helmOverrides,
   };
 }
 

+ 17 - 2
dashboard/src/main/home/app-dashboard/app-view/AppDataContainer.tsx

@@ -1,4 +1,4 @@
-import React, { useCallback, useEffect, useMemo, useState } from "react";
+import React, {useCallback, useContext, useEffect, useMemo, useState} from "react";
 import { FieldErrors, FormProvider, useForm } from "react-hook-form";
 import {
   PorterAppFormData,
@@ -38,6 +38,10 @@ import { useAppAnalytics } from "lib/hooks/useAppAnalytics";
 import { Error as ErrorComponent } from "components/porter/Error";
 import _ from "lodash";
 import axios from "axios";
+import HelmEditorTab from "./tabs/HelmEditorTab";
+import HelmLatestValues from "../validate-apply/helm/HelmLatestValues";
+import HelmLatestValuesTab from "./tabs/HelmLatestValuesTab";
+import {Context} from "../../../../shared/Context";
 
 // commented out tabs are not yet implemented
 // will be included as support is available based on data from app revisions rather than helm releases
@@ -52,7 +56,8 @@ const validTabs = [
   "build-settings",
   "image-settings",
   "settings",
-  // "helm-values",
+  "helm-overrides",
+  "helm-values",
   "job-history",
 ] as const;
 const DEFAULT_TAB = "activity";
@@ -70,6 +75,8 @@ const AppDataContainer: React.FC<AppDataContainerProps> = ({ tabParam }) => {
   const queryClient = useQueryClient();
   const [confirmDeployModalOpen, setConfirmDeployModalOpen] = useState(false);
 
+  const { currentProject, user } = useContext(Context);
+
   const { updateAppStep } = useAppAnalytics();
 
   const {
@@ -408,6 +415,12 @@ const AppDataContainer: React.FC<AppDataContainerProps> = ({ tabParam }) => {
       label: "Image Settings",
       value: "image-settings",
     });
+    {(currentProject?.helm_values_enabled || user?.isPorterUser) &&
+      base.push({ label: "Helm Overrides", value: "helm-overrides" });
+    }
+    {user?.isPorterUser &&
+      base.push({ label: "Latest Helm Values", value: "helm-values" });
+    }
     base.push({ label: "Settings", value: "settings" });
     return base;
   }, [deploymentTarget.preview, latestProto.build]);
@@ -537,6 +550,8 @@ const AppDataContainer: React.FC<AppDataContainerProps> = ({ tabParam }) => {
           .with("metrics", () => <MetricsTab />)
           .with("events", () => <EventFocusView />)
           .with("job-history", () => <JobsTab />)
+          .with("helm-overrides", () => <HelmEditorTab buttonStatus={buttonStatus} featureFlagEnabled={currentProject?.helm_values_enabled ?? false}/>)
+          .with("helm-values", () => <HelmLatestValuesTab />)
           .otherwise(() => null)}
         <Spacer y={2} />
       </form>

+ 73 - 0
dashboard/src/main/home/app-dashboard/app-view/tabs/HelmEditorTab.tsx

@@ -0,0 +1,73 @@
+import React from "react";
+import { useLatestRevision } from "../LatestRevisionContext";
+import HelmOverrides from "../../validate-apply/helm/HelmOverrides";
+import { useFormContext } from "react-hook-form";
+import { PorterAppFormData } from "../../../../../lib/porter-apps";
+import Button from "../../../../../components/porter/Button";
+import { ButtonStatus } from "../AppDataContainer";
+import yaml from "js-yaml";
+import Text from "../../../../../components/porter/Text";
+import Spacer from "../../../../../components/porter/Spacer";
+
+type Props = {
+  buttonStatus: ButtonStatus;
+  featureFlagEnabled: boolean;
+};
+
+const HelmEditorTab: React.FC<Props> = ({ buttonStatus, featureFlagEnabled }) => {
+  const {
+    watch,
+    formState: { isSubmitting, errors },
+    setValue,
+  } = useFormContext<PorterAppFormData>();
+
+  const overrides = watch("app.helmOverrides");
+  const {
+    projectId,
+    clusterId,
+    latestProto,
+    deploymentTarget,
+    porterApp,
+    latestRevision,
+  } = useLatestRevision();
+
+  const appName = latestProto.name;
+
+  const [error, setError] = React.useState<string>("");
+
+  return (
+    <>
+      {!featureFlagEnabled && <Text color="helper">This tab is only visible to Porter operators. Enable the feature flag to allow customers to view this.</Text>}
+      <HelmOverrides
+        projectId={projectId}
+        clusterId={clusterId}
+        appName={appName}
+        deploymentTargetId={deploymentTarget.id}
+        appId={porterApp.id}
+        overrideValues={overrides ? yaml.dump(JSON.parse( overrides)) : ""}
+        setError={setError}
+      />
+      {error !== "" && <Text color="helper">{error}</Text>}
+      <Spacer y={1} />
+      <Button
+        type="submit"
+        status={buttonStatus}
+        disabled={
+          isSubmitting ||
+          latestRevision.status === "CREATED" ||
+          latestRevision.status === "AWAITING_BUILD_ARTIFACT" ||
+          error !== ""
+        }
+        disabledTooltipMessage={
+          error !== ""
+            ? "Error parsing yaml"
+            : "Please wait for the new values to apply to complete before updating helm overrides again"
+        }
+      >
+        Save Helm overrides
+      </Button>
+    </>
+  );
+};
+
+export default HelmEditorTab;

+ 29 - 0
dashboard/src/main/home/app-dashboard/app-view/tabs/HelmLatestValuesTab.tsx

@@ -0,0 +1,29 @@
+import React from "react";
+import { useLatestRevision } from "../LatestRevisionContext";
+import HelmLatestValues from "../../validate-apply/helm/HelmLatestValues";
+import Text from "components/porter/Text";
+import Spacer from "components/porter/Spacer";
+
+const HelmLatestValuesTab: React.FC = () => {
+    const { projectId, clusterId, latestProto, deploymentTarget, porterApp, latestRevision } = useLatestRevision();
+
+    const appName = latestProto.name
+
+    return (
+        <>
+        <Text color="helper">
+            This tab is only visible to Porter operators.
+        </Text>
+        <Spacer y={1} />
+        <HelmLatestValues
+            projectId={projectId}
+            clusterId={clusterId}
+            appName={appName}
+            deploymentTargetId={deploymentTarget.id}
+            appId={porterApp.id}
+        />
+        </>
+    );
+};
+
+export default HelmLatestValuesTab;

+ 164 - 0
dashboard/src/main/home/app-dashboard/validate-apply/helm/HelmLatestValues.tsx

@@ -0,0 +1,164 @@
+import React, { useEffect, useMemo, useState } from "react";
+import styled from "styled-components";
+
+import api from "shared/api";
+
+import { useQuery } from "@tanstack/react-query";
+import yaml from "js-yaml";
+import YamlEditor from "components/YamlEditor";
+import Spacer from "components/porter/Spacer";
+import Text from "components/porter/Text";
+import Checkbox from "components/porter/Checkbox";
+import {z} from "zod";
+import {match} from "ts-pattern/dist";
+import loading from "assets/loading.gif";
+
+type PropsType = {
+  projectId: number;
+  clusterId: number;
+  appName: string;
+  appId: number;
+  deploymentTargetId: string;
+};
+
+const HelmLatestValues: React.FunctionComponent<PropsType> = ({
+  projectId,
+  clusterId,
+  appName,
+  appId,
+  deploymentTargetId,
+}) => {
+
+    const [withDefaults, setWithDefaults] = React.useState<boolean>(false);
+
+    const res = useQuery(
+      [
+        "getAppHelmValues",
+        projectId,
+        clusterId,
+        appName,
+        appId,
+        deploymentTargetId,
+        withDefaults,
+      ],
+      async () => {
+
+          const helmValues = await api.appHelmValues(
+              "<token>",
+              {
+                app_id: appId,
+                deployment_target_id: deploymentTargetId,
+                with_defaults: withDefaults,
+              },
+              {
+                project_id: projectId,
+                cluster_id: clusterId,
+                porter_app_name: appName,
+              }
+          );
+
+          const parsed = await z.object({helm_values: z.string()}).parseAsync(helmValues.data);
+
+          return yaml.dump(JSON.parse(parsed.helm_values));
+      },
+      {
+        enabled: appName !== "",
+        refetchOnWindowFocus: false,
+      }
+  );
+
+  return (
+      <>
+      <Checkbox
+          checked={withDefaults}
+          toggleChecked={() => setWithDefaults(!withDefaults)}
+      >
+          <Text color="helper">
+              Include default Helm values
+          </Text>
+      </Checkbox>
+      <Spacer y={1} />
+          {match(res)
+              .with({ status: "loading" }, () => (
+                  <LoadingPlaceholder>
+                      <StatusWrapper>
+                          <LoadingGif src={loading} revision={false} /> Updating . . .
+                      </StatusWrapper>
+                  </LoadingPlaceholder>
+              ))
+              .with({ status: "success" }, ({ data }) => (
+                  <StyledValuesYaml>
+                      <Wrapper>
+                          <YamlEditor
+                              value={data}
+                              height="calc(100vh - 412px)"
+                              readOnly={true}
+                          />
+                      </Wrapper>
+                      <Spacer y={0.5} />
+                  </StyledValuesYaml>
+              ))
+              .otherwise(() => null)}
+      </>
+  );
+
+}
+
+export default HelmLatestValues;
+
+const Wrapper = styled.div`
+  overflow: auto;
+  border-radius: 8px;
+  border: 1px solid #ffffff33;
+`;
+
+const StyledValuesYaml = styled.div`
+  display: flex;
+  flex-direction: column;
+  width: 100%;
+  height: calc(100vh - 350px);
+  font-size: 13px;
+  overflow: hidden;
+  border-radius: 8px;
+  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);
+    }
+  }
+`;
+
+const StatusWrapper = styled.div`
+  display: flex;
+  align-items: center;
+  font-family: "Work Sans", sans-serif;
+  font-size: 13px;
+  color: #ffffff55;
+  margin-right: 25px;
+`;
+
+
+const LoadingPlaceholder = styled.div`
+  height: 40px;
+  display: flex;
+  align-items: center;
+  padding-left: 20px;
+`;
+
+const LoadingGif = styled.img`
+  width: 15px;
+  height: 15px;
+  margin-right: ${(props: { revision: boolean }) =>
+    props.revision ? "0px" : "9px"};
+  margin-left: ${(props: { revision: boolean }) =>
+    props.revision ? "10px" : "0px"};
+  margin-bottom: ${(props: { revision: boolean }) =>
+    props.revision ? "-2px" : "0px"};
+`;

+ 86 - 0
dashboard/src/main/home/app-dashboard/validate-apply/helm/HelmOverrides.tsx

@@ -0,0 +1,86 @@
+import React from "react";
+import styled from "styled-components";
+
+import yaml from "js-yaml";
+import YamlEditor from "components/YamlEditor";
+import Spacer from "components/porter/Spacer";
+import Text from "components/porter/Text";
+import {useFormContext} from "react-hook-form";
+import {PorterAppFormData} from "lib/porter-apps";
+
+type PropsType = {
+  projectId: number;
+  clusterId: number;
+  appName: string;
+  appId: number;
+  deploymentTargetId: string;
+  overrideValues: any;
+  setError: (errorString: string) => void;
+};
+
+const HelmOverrides: React.FunctionComponent<PropsType> = ({
+  overrideValues,
+    setError,
+}) => {
+
+  const { setValue } = useFormContext<PorterAppFormData>();
+  const [currentOverrideValues, setOverrideValues] = React.useState<string>(overrideValues);
+
+  const setFormValue = (value: string) => {
+        setOverrideValues(value);
+        try {
+            const jsonValues = value.trim() ? JSON.stringify(yaml.load(value)) : ""
+            setValue("app.helmOverrides", jsonValues);
+            setError("");
+        } catch (e) {
+            setError(e.toString());
+        }
+  }
+
+  return (
+        <StyledValuesYaml>
+            <Spacer y={.5} />
+            <Text color="warner">Note: Values set in Helm overrides will take precedence over corresponding fields in the UI, causing the dashboard to be out of sync.</Text>
+            <Spacer y={.5} />
+            <Wrapper>
+                <YamlEditor
+                    value={currentOverrideValues}
+                    height="calc(100vh - 412px)"
+                    onChange={setFormValue}
+                />
+            </Wrapper>
+        </StyledValuesYaml>
+  );
+
+}
+
+export default HelmOverrides;
+
+const Wrapper = styled.div`
+  overflow: auto;
+  border-radius: 8px;
+  border: 1px solid #ffffff33;
+`;
+
+const StyledValuesYaml = styled.div`
+  display: flex;
+  flex-direction: column;
+  width: 100%;
+  height: calc(100vh - 350px);
+  font-size: 13px;
+  overflow: hidden;
+  border-radius: 8px;
+  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);
+    }
+  }
+`;

+ 18 - 0
dashboard/src/shared/api.tsx

@@ -301,6 +301,23 @@ const appLogs = baseApi<
     `/api/projects/${project_id}/clusters/${cluster_id}/apps/${porter_app_name}/logs`
 );
 
+const appHelmValues = baseApi<
+    {
+        app_id: number;
+        deployment_target_id: string;
+        with_defaults: boolean;
+    },
+    {
+        project_id: number;
+        cluster_id: number;
+        porter_app_name: string;
+    }
+>(
+    "GET",
+    ({ project_id, cluster_id, porter_app_name }) =>
+        `/api/projects/${project_id}/clusters/${cluster_id}/apps/${porter_app_name}/helm-values`
+);
+
 const appJobs = baseApi<
   {
     deployment_target_id: string;
@@ -3198,6 +3215,7 @@ export default {
   getClusterState,
   getMetrics,
   appMetrics,
+  appHelmValues,
   getNamespaces,
   getNGINXIngresses,
   getOAuthIds,

+ 1 - 1
go.mod

@@ -82,7 +82,7 @@ require (
 	github.com/matryer/is v1.4.0
 	github.com/nats-io/nats.go v1.24.0
 	github.com/open-policy-agent/opa v0.44.0
-	github.com/porter-dev/api-contracts v0.2.14
+	github.com/porter-dev/api-contracts v0.2.15
 	github.com/riandyrn/otelchi v0.5.1
 	github.com/santhosh-tekuri/jsonschema/v5 v5.0.1
 	github.com/stefanmcshane/helm v0.0.0-20221213002717-88a4a2c6e77d

+ 2 - 2
go.sum

@@ -1516,8 +1516,8 @@ github.com/pmezard/go-difflib v0.0.0-20151028094244-d8ed2627bdf0/go.mod h1:iKH77
 github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
 github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
 github.com/polyfloyd/go-errorlint v0.0.0-20210722154253-910bb7978349/go.mod h1:wi9BfjxjF/bwiZ701TzmfKu6UKC357IOAtNr0Td0Lvw=
-github.com/porter-dev/api-contracts v0.2.14 h1:ZjA/2ViDf2CkFsSTJ9Kffo4YTj/xeLc0JiAe4FfGxY0=
-github.com/porter-dev/api-contracts v0.2.14/go.mod h1:fX6JmP5QuzxDLvqP3evFOTXjI4dHxsG0+VKNTjImZU8=
+github.com/porter-dev/api-contracts v0.2.15 h1:o8Mk2hx0flRl8TXG+PVhMvphSPk1McfO/Frx82udr0Q=
+github.com/porter-dev/api-contracts v0.2.15/go.mod h1:fX6JmP5QuzxDLvqP3evFOTXjI4dHxsG0+VKNTjImZU8=
 github.com/porter-dev/switchboard v0.0.3 h1:dBuYkiVLa5Ce7059d6qTe9a1C2XEORFEanhbtV92R+M=
 github.com/porter-dev/switchboard v0.0.3/go.mod h1:xSPzqSFMQ6OSbp42fhCi4AbGbQbsm6nRvOkrblFeXU4=
 github.com/posener/complete v1.1.1/go.mod h1:em0nMJCgc9GFtwrmVmEMR/ZL6WyhyjMBndrE9hABlRI=

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

@@ -277,6 +277,7 @@ func QueryPrometheus(
 func getNginxStatusQuery(opts *QueryOpts, selectionRegex string) (string, error) {
 	var queries []string
 
+	// we recently changed the way labels are read into prometheus, which has removed the 'exported_' prepended to certain labels
 	namespaceLabels := []string{"exported_namespace", "namespace"}
 	for _, namespaceLabel := range namespaceLabels {
 		queries = append(queries, fmt.Sprintf(`round(sum by (status_code, ingress)(label_replace(increase(nginx_ingress_controller_requests{%s=~"%s",ingress="%s",service="%s"}[2m]), "status_code", "${1}xx", "status", "(.)..")), 0.001)`, namespaceLabel, opts.Namespace, selectionRegex, opts.Name))