Procházet zdrojové kódy

Show Porter TLS enabled checkbox so they can disable it (only for ALB clusters) (#3992)

Feroze Mohideen před 2 roky
rodič
revize
b49b0e1ed1

+ 7 - 7
dashboard/package-lock.json

@@ -91,7 +91,7 @@
         "@babel/preset-typescript": "^7.15.0",
         "@ianvs/prettier-plugin-sort-imports": "^4.1.1",
         "@pmmmwh/react-refresh-webpack-plugin": "^0.4.3",
-        "@porter-dev/api-contracts": "^0.2.28",
+        "@porter-dev/api-contracts": "^0.2.51",
         "@testing-library/jest-dom": "^4.2.4",
         "@testing-library/react": "^9.3.2",
         "@testing-library/user-event": "^7.1.2",
@@ -2658,9 +2658,9 @@
       }
     },
     "node_modules/@porter-dev/api-contracts": {
-      "version": "0.2.28",
-      "resolved": "https://registry.npmjs.org/@porter-dev/api-contracts/-/api-contracts-0.2.28.tgz",
-      "integrity": "sha512-+UCD2ukvdjMkYEGCORSgXG3yZ6m//XzC9zEWuBf/p+iaztW5dM3vYaIDVpqDEzvndihLRekYrqVeD6cE/AoONQ==",
+      "version": "0.2.51",
+      "resolved": "https://registry.npmjs.org/@porter-dev/api-contracts/-/api-contracts-0.2.51.tgz",
+      "integrity": "sha512-DjqEgB7gZbCuLwCoYyHmdtzMxlyPYCJPElmQOXDFqyX9FZBIYl3GTRgjITl7n0t7VzI8u5VALejK4F6vLfF3kQ==",
       "dev": true,
       "dependencies": {
         "@bufbuild/protobuf": "^1.1.0"
@@ -19826,9 +19826,9 @@
       "integrity": "sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A=="
     },
     "@porter-dev/api-contracts": {
-      "version": "0.2.28",
-      "resolved": "https://registry.npmjs.org/@porter-dev/api-contracts/-/api-contracts-0.2.28.tgz",
-      "integrity": "sha512-+UCD2ukvdjMkYEGCORSgXG3yZ6m//XzC9zEWuBf/p+iaztW5dM3vYaIDVpqDEzvndihLRekYrqVeD6cE/AoONQ==",
+      "version": "0.2.51",
+      "resolved": "https://registry.npmjs.org/@porter-dev/api-contracts/-/api-contracts-0.2.51.tgz",
+      "integrity": "sha512-DjqEgB7gZbCuLwCoYyHmdtzMxlyPYCJPElmQOXDFqyX9FZBIYl3GTRgjITl7n0t7VzI8u5VALejK4F6vLfF3kQ==",
       "dev": true,
       "requires": {
         "@bufbuild/protobuf": "^1.1.0"

+ 1 - 1
dashboard/package.json

@@ -98,7 +98,7 @@
     "@babel/preset-typescript": "^7.15.0",
     "@ianvs/prettier-plugin-sort-imports": "^4.1.1",
     "@pmmmwh/react-refresh-webpack-plugin": "^0.4.3",
-    "@porter-dev/api-contracts": "^0.2.28",
+    "@porter-dev/api-contracts": "^0.2.51",
     "@testing-library/jest-dom": "^4.2.4",
     "@testing-library/react": "^9.3.2",
     "@testing-library/user-event": "^7.1.2",

+ 38 - 20
dashboard/src/lib/hooks/useClusterResourceLimits.ts

@@ -1,5 +1,9 @@
 import { useEffect, useState } from "react";
-import { Cluster, NodeGroupType } from "@porter-dev/api-contracts";
+import {
+  Contract,
+  LoadBalancerType,
+  NodeGroupType,
+} from "@porter-dev/api-contracts";
 import { useQuery } from "@tanstack/react-query";
 import convert from "convert";
 import { match } from "ts-pattern";
@@ -12,6 +16,8 @@ import api from "shared/api";
 const DEFAULT_INSTANCE_CLASS = "t3";
 const DEFAULT_INSTANCE_SIZE = "medium";
 
+export type ClientLoadBalancerType = "ALB" | "NLB" | "UNSPECIFIED";
+
 const encodedContractValidator = z.object({
   ID: z.number(),
   CreatedAt: z.string(),
@@ -82,10 +88,7 @@ const clusterNodesValidator = z
       return defaultResources;
     }
     const [instanceClass, instanceSize] = res.data;
-    if (
-      AWS_INSTANCE_LIMITS[instanceClass] &&
-      AWS_INSTANCE_LIMITS[instanceClass][instanceSize]
-    ) {
+    if (AWS_INSTANCE_LIMITS[instanceClass]?.[instanceSize]) {
       const { vCPU, RAM } = AWS_INSTANCE_LIMITS[instanceClass][instanceSize];
       return {
         maxCPU: vCPU,
@@ -113,6 +116,7 @@ export const useClusterResourceLimits = ({
   defaultRAM: number;
   clusterContainsGPUNodes: boolean;
   clusterIngressIp: string;
+  loadBalancerType: ClientLoadBalancerType;
 } => {
   const SMALL_INSTANCE_UPPER_BOUND = 0.75;
   const LARGE_INSTANCE_UPPER_BOUND = 0.9;
@@ -149,6 +153,8 @@ export const useClusterResourceLimits = ({
     ) * 100
   );
   const [clusterIngressIp, setClusterIngressIp] = useState<string>("");
+  const [loadBalancerType, setLoadBalancerType] =
+    useState<ClientLoadBalancerType>("UNSPECIFIED");
 
   const getClusterNodes = useQuery(
     ["getClusterNodes", projectId, clusterId],
@@ -189,21 +195,18 @@ export const useClusterResourceLimits = ({
       const contracts = await z
         .array(encodedContractValidator)
         .parseAsync(res.data);
-      // Use zod to validate the data
-      const latestContract = contracts
-        .filter((contract) => contract.cluster_id === clusterId) // Filter contracts by the currentCluster.id
-        .sort(
-          (a, b) =>
-            new Date(b.CreatedAt).getTime() - new Date(a.CreatedAt).getTime()
-        ) // Sort them by the CreatedAt date in descending order
-        .map((contract) => contract)[0];
-
-      const decodedContract = Cluster.fromJsonString(
-        atob(latestContract.base64_contract)
-      );
-      // Check for NODE_GROUP_TYPE_CUSTOM with instanceType containing "g4dn"
-
-      return decodedContract;
+      if (contracts.length) {
+        const latestContract = contracts
+          .filter((contract) => contract.cluster_id === clusterId)
+          .sort(
+            (a, b) =>
+              new Date(b.CreatedAt).getTime() - new Date(a.CreatedAt).getTime()
+          )[0];
+        const decodedContract = Contract.fromJsonString(
+          atob(latestContract.base64_contract)
+        );
+        return decodedContract.cluster;
+      }
     },
     {
       enabled: !!projectId,
@@ -286,7 +289,21 @@ export const useClusterResourceLimits = ({
         })
         .otherwise(() => false);
 
+      const loadBalancerType: ClientLoadBalancerType = match(contract)
+        .with({ kindValues: { case: "eksKind" } }, (c) => {
+          const loadBalancer = c.kindValues.value.loadBalancer;
+          if (!loadBalancer) {
+            return "UNSPECIFIED";
+          }
+          return match(loadBalancer.loadBalancerType)
+            .with(LoadBalancerType.ALB, (): ClientLoadBalancerType => "ALB")
+            .with(LoadBalancerType.NLB, (): ClientLoadBalancerType => "NLB")
+            .otherwise((): ClientLoadBalancerType => "UNSPECIFIED");
+        })
+        .otherwise(() => "UNSPECIFIED");
+
       setClusterContainsGPUNodes(containsCustomNodeGroup);
+      setLoadBalancerType(loadBalancerType);
     }
   }, [contract]);
 
@@ -297,6 +314,7 @@ export const useClusterResourceLimits = ({
     defaultRAM,
     clusterContainsGPUNodes,
     clusterIngressIp,
+    loadBalancerType,
   };
 };
 

+ 12 - 12
dashboard/src/lib/hooks/usePorterYaml.ts

@@ -1,12 +1,14 @@
+import { useCallback, useContext, useEffect, useState } from "react";
 import { PorterApp } from "@porter-dev/api-contracts";
 import { useQuery } from "@tanstack/react-query";
-import { SourceOptions, serviceOverrides } from "lib/porter-apps";
-import { DetectedServices } from "lib/porter-apps/services";
-import { useCallback, useContext, useEffect, useState } from "react";
+import { z } from "zod";
+
+import { serviceOverrides, type SourceOptions } from "lib/porter-apps";
+import { type DetectedServices } from "lib/porter-apps/services";
+
+import api from "shared/api";
 import { useClusterResources } from "shared/ClusterResourcesContext";
 import { Context } from "shared/Context";
-import api from "shared/api";
-import { z } from "zod";
 
 type PorterYamlStatus =
   | {
@@ -40,10 +42,8 @@ export const usePorterYaml = ({
 }): PorterYamlStatus => {
   const { currentProject, currentCluster } = useContext(Context);
   const { currentClusterResources } = useClusterResources();
-  const [
-    detectedServices,
-    setDetectedServices,
-  ] = useState<DetectedServices | null>(null);
+  const [detectedServices, setDetectedServices] =
+    useState<DetectedServices | null>(null);
   const [detectedName, setDetectedName] = useState<string | null>(null);
   const [porterYamlFound, setPorterYamlFound] = useState(false);
 
@@ -76,7 +76,7 @@ export const usePorterYaml = ({
       );
 
       setPorterYamlFound(true);
-      return z.string().parseAsync(res.data);
+      return await z.string().parseAsync(res.data);
     },
     {
       enabled:
@@ -193,9 +193,9 @@ export const usePorterYaml = ({
     }
 
     if (data) {
-      detectServices({
+      void detectServices({
         b64Yaml: data,
-        appName: appName,
+        appName,
         projectId: currentProject.id,
         clusterId: currentCluster.id,
       });

+ 42 - 19
dashboard/src/lib/porter-apps/services.ts

@@ -1,8 +1,13 @@
-import { PorterApp, Service, ServiceType } from "@porter-dev/api-contracts";
+import {
+  Service,
+  ServiceType,
+  type PorterApp,
+} from "@porter-dev/api-contracts";
+import _ from "lodash";
 import { match } from "ts-pattern";
 import { z } from "zod";
 
-import { BuildOptions } from "./build";
+import { type BuildOptions } from "./build";
 import {
   autoscalingValidator,
   deserializeAutoscaling,
@@ -11,15 +16,14 @@ import {
   healthcheckValidator,
   ingressAnnotationsValidator,
   serializeAutoscaling,
-  SerializedAutoscaling,
-  SerializedHealthcheck,
   serializeHealth,
   serviceBooleanValidator,
   ServiceField,
   serviceNumberValidator,
   serviceStringValidator,
+  type SerializedAutoscaling,
+  type SerializedHealthcheck,
 } from "./values";
-import _ from "lodash";
 
 const LAUNCHER_PREFIX = "/cnb/lifecycle/launcher ";
 
@@ -42,6 +46,7 @@ const webConfigValidator = z.object({
   healthCheck: healthcheckValidator.optional(),
   private: serviceBooleanValidator.optional(),
   ingressAnnotations: ingressAnnotationsValidator.default([]),
+  disableTls: serviceBooleanValidator.optional(),
 });
 export type ClientWebConfig = z.infer<typeof webConfigValidator>;
 
@@ -114,12 +119,13 @@ export type SerializedService = {
   config:
     | {
         type: "web";
-        domains: {
+        domains: Array<{
           name: string;
-        }[];
+        }>;
         autoscaling?: SerializedAutoscaling;
         healthCheck?: SerializedHealthcheck;
         private?: boolean;
+        disableTls?: boolean;
         ingressAnnotations: Record<string, string>;
       }
     | {
@@ -138,11 +144,13 @@ export type SerializedService = {
       };
 };
 
-export function isPredeployService(service: SerializedService | ClientService) {
-  return service.config.type == "predeploy";
+export function isPredeployService(
+  service: SerializedService | ClientService
+): boolean {
+  return service.config.type === "predeploy";
 }
 
-export function prefixSubdomain(subdomain: string) {
+export function prefixSubdomain(subdomain: string): string {
   if (subdomain.startsWith("https://") || subdomain.startsWith("http://")) {
     return subdomain;
   }
@@ -212,6 +220,7 @@ export function defaultSerialized({
         domains: [],
         private: false,
         ingressAnnotations: {},
+        disableTls: false,
       },
     }))
     .with("worker", () => ({
@@ -262,7 +271,9 @@ export function serializeService(service: ClientService): SerializedService {
           }),
           healthCheck: serializeHealth({ health: config.healthCheck }),
           domains: config.domains.map((domain) => ({
-            name: domain.name.value.replace("https://", "").replace("http://", ""),
+            name: domain.name.value
+              .replace("https://", "")
+              .replace("http://", ""),
           })),
           ingressAnnotations: Object.fromEntries(
             config.ingressAnnotations
@@ -270,6 +281,7 @@ export function serializeService(service: ClientService): SerializedService {
               .map((annotation) => [annotation.key, annotation.value])
           ),
           private: config.private?.value,
+          disableTls: config.disableTls?.value,
         })
       )
       .with({ type: "worker" }, (config) =>
@@ -351,7 +363,7 @@ export function deserializeService({
   return match(service.config)
     .with({ type: "web" }, (config) => {
       const overrideWebConfig =
-        override?.config.type == "web" ? override.config : undefined;
+        override?.config.type === "web" ? override.config : undefined;
 
       const uniqueDomains = Array.from(
         new Set([
@@ -389,19 +401,19 @@ export function deserializeService({
           autoscaling: deserializeAutoscaling({
             autoscaling: config.autoscaling,
             override: overrideWebConfig?.autoscaling,
-            setDefaults: setDefaults,
+            setDefaults,
           }),
           healthCheck: deserializeHealthCheck({
             health: config.healthCheck,
             override: overrideWebConfig?.healthCheck,
-            setDefaults: setDefaults,
+            setDefaults,
           }),
 
           domains: uniqueDomains.map((domain) => ({
             name: ServiceField.string(
               domain.name,
               overrideWebConfig?.domains.find(
-                (overrideDomain) => overrideDomain.name == domain.name
+                (overrideDomain) => overrideDomain.name === domain.name
               )?.name
             ),
           })),
@@ -413,12 +425,22 @@ export function deserializeService({
               : setDefaults
               ? ServiceField.boolean(false, undefined)
               : undefined,
+          disableTls:
+            typeof config.disableTls === "boolean" ||
+            typeof overrideWebConfig?.disableTls === "boolean"
+              ? ServiceField.boolean(
+                  config.disableTls,
+                  overrideWebConfig?.disableTls
+                )
+              : setDefaults
+              ? ServiceField.boolean(false, undefined)
+              : undefined,
         },
       };
     })
     .with({ type: "worker" }, (config) => {
       const overrideWorkerConfig =
-        override?.config.type == "worker" ? override.config : undefined;
+        override?.config.type === "worker" ? override.config : undefined;
 
       return {
         ...baseService,
@@ -427,14 +449,14 @@ export function deserializeService({
           autoscaling: deserializeAutoscaling({
             autoscaling: config.autoscaling,
             override: overrideWorkerConfig?.autoscaling,
-            setDefaults: setDefaults,
+            setDefaults,
           }),
         },
       };
     })
     .with({ type: "job" }, (config) => {
       const overrideJobConfig =
-        override?.config.type == "job" ? override.config : undefined;
+        override?.config.type === "job" ? override.config : undefined;
 
       return {
         ...baseService,
@@ -462,7 +484,7 @@ export function deserializeService({
               ? ServiceField.boolean(false, undefined)
               : undefined,
           timeoutSeconds:
-            config.timeoutSeconds != 0
+            config.timeoutSeconds !== 0
               ? ServiceField.number(
                   config.timeoutSeconds,
                   overrideJobConfig?.timeoutSeconds
@@ -586,6 +608,7 @@ export function serializedServiceFromProto({
         type: "web" as const,
         autoscaling: value.autoscaling ? value.autoscaling : undefined,
         healthCheck: value.healthCheck ? value.healthCheck : undefined,
+        disableTls: value.disableTls ? value.disableTls : undefined,
         ...value,
       },
     }))

+ 47 - 32
dashboard/src/main/home/app-dashboard/validate-apply/services-settings/ServiceContainer.tsx

@@ -1,30 +1,33 @@
 import React, { useCallback, useEffect, useState } from "react";
+import _ from "lodash";
 import AnimateHeight, { type Height } from "react-animate-height";
+import { type UseFieldArrayUpdate } from "react-hook-form";
 import styled, { keyframes } from "styled-components";
-import _ from "lodash";
+import { match } from "ts-pattern";
+
+import Spacer from "components/porter/Spacer";
+import { type PorterAppVersionStatus } from "lib/hooks/useAppStatus";
+import useResizeObserver from "lib/hooks/useResizeObserver";
+import { type PorterAppFormData } from "lib/porter-apps";
+import { type ClientService } from "lib/porter-apps/services";
 
-import web from "assets/web.png";
 import chip from "assets/computer-chip.svg";
-import gpu from "assets/lightning.svg";
-import worker from "assets/worker.png";
 import job from "assets/job.png";
-import Text from "components/porter/Text";
-import Spacer from "components/porter/Spacer";
+import web from "assets/web.png";
+import worker from "assets/worker.png";
+
+import ServiceStatusFooter from "./ServiceStatusFooter";
+import JobTabs from "./tabs/JobTabs";
 import WebTabs from "./tabs/WebTabs";
 import WorkerTabs from "./tabs/WorkerTabs";
-import JobTabs from "./tabs/JobTabs";
-import { type ClientService } from "lib/porter-apps/services";
-import { type UseFieldArrayUpdate } from "react-hook-form";
-import { type PorterAppFormData } from "lib/porter-apps";
-import { match } from "ts-pattern";
-import useResizeObserver from "lib/hooks/useResizeObserver";
-import { type PorterAppVersionStatus } from "lib/hooks/useAppStatus";
-import ServiceStatusFooter from "./ServiceStatusFooter";
 
 type ServiceProps = {
   index: number;
   service: ClientService;
-  update: UseFieldArrayUpdate<PorterAppFormData, "app.services" | "app.predeploy">;
+  update: UseFieldArrayUpdate<
+    PorterAppFormData,
+    "app.services" | "app.predeploy"
+  >;
   remove: (index: number) => void;
   status?: PorterAppVersionStatus[];
   maxCPU: number;
@@ -35,7 +38,8 @@ type ServiceProps = {
     appName: string;
   };
   clusterIngressIp: string;
-}
+  showDisableTls: boolean;
+};
 
 const ServiceContainer: React.FC<ServiceProps> = ({
   index,
@@ -48,6 +52,7 @@ const ServiceContainer: React.FC<ServiceProps> = ({
   clusterContainsGPUNodes,
   internalNetworkingDetails,
   clusterIngressIp,
+  showDisableTls,
 }) => {
   const [height, setHeight] = useState<Height>(service.expanded ? "auto" : 0);
 
@@ -71,7 +76,7 @@ const ServiceContainer: React.FC<ServiceProps> = ({
     }
   }, [service.expanded]);
 
-  const renderTabs = (service: ClientService) => {
+  const renderTabs = (service: ClientService): JSX.Element => {
     return match(service)
       .with({ config: { type: "web" } }, (svc) => (
         <WebTabs
@@ -82,6 +87,7 @@ const ServiceContainer: React.FC<ServiceProps> = ({
           clusterContainsGPUNodes={clusterContainsGPUNodes}
           internalNetworkingDetails={internalNetworkingDetails}
           clusterIngressIp={clusterIngressIp}
+          showDisableTls={showDisableTls}
         />
       ))
       .with({ config: { type: "worker" } }, (svc) => (
@@ -94,7 +100,13 @@ const ServiceContainer: React.FC<ServiceProps> = ({
         />
       ))
       .with({ config: { type: "job" } }, (svc) => (
-        <JobTabs index={index} service={svc} maxCPU={maxCPU} maxRAM={maxRAM} clusterContainsGPUNodes={clusterContainsGPUNodes} />
+        <JobTabs
+          index={index}
+          service={svc}
+          maxCPU={maxCPU}
+          maxRAM={maxRAM}
+          clusterContainsGPUNodes={clusterContainsGPUNodes}
+        />
       ))
       .with({ config: { type: "predeploy" } }, (svc) => (
         <JobTabs
@@ -109,7 +121,7 @@ const ServiceContainer: React.FC<ServiceProps> = ({
       .exhaustive();
   };
 
-  const renderIcon = (service: ClientService) => {
+  const renderIcon = (service: ClientService): JSX.Element => {
     switch (service.config.type) {
       case "web":
         return <Icon src={web} />;
@@ -142,12 +154,15 @@ const ServiceContainer: React.FC<ServiceProps> = ({
           {service.name.value.trim().length > 0
             ? service.name.value
             : "New Service"}
-          {service.gpuCoresNvidia.value > 0 &&
-            <><Spacer inline x={1.5} /><TagContainer>
-              <ChipIcon src={chip} alt="Chip Icon" />
-              <TagText>GPU Workload</TagText>
-            </TagContainer></>
-          }
+          {service.gpuCoresNvidia.value > 0 && (
+            <>
+              <Spacer inline x={1.5} />
+              <TagContainer>
+                <ChipIcon src={chip} alt="Chip Icon" />
+                <TagText>GPU Workload</TagText>
+              </TagContainer>
+            </>
+          )}
         </ServiceTitle>
 
         {service.canDelete && (
@@ -260,8 +275,8 @@ const ServiceHeader = styled.div<{
     cursor: pointer;
     border-radius: 20px;
     margin-left: -10px;
-    transform: ${(props: { showExpanded?: boolean; }) =>
-    props.showExpanded ? "" : "rotate(-90deg)"};
+    transform: ${(props: { showExpanded?: boolean }) =>
+      props.showExpanded ? "" : "rotate(-90deg)"};
   }
 `;
 
@@ -290,8 +305,8 @@ const TagContainer = styled.div`
   height: 30px;
   background-image: linear-gradient(
     45deg,
-    rgba(255, 255, 255, 0.05) 25%, 
-    rgba(255, 255, 255, 0.2) 50%, 
+    rgba(255, 255, 255, 0.05) 25%,
+    rgba(255, 255, 255, 0.2) 50%,
     rgba(255, 255, 255, 0.05) 75%
   );
   background-size: 200% 200%;
@@ -307,10 +322,10 @@ const ChipIcon = styled.img`
 `;
 
 const TagText = styled.span`
-  font-family: 'General Sans';
+  font-family: "General Sans";
   font-weight: 400;
   font-size: 10px;
   line-height: 100%;
   letter-spacing: -0.02em;
-  color: #FFFFFF;
-`;
+  color: #ffffff;
+`;

+ 48 - 29
dashboard/src/main/home/app-dashboard/validate-apply/services-settings/ServiceList.tsx

@@ -1,34 +1,36 @@
 import React, { useEffect, useMemo, useState } from "react";
-import ServiceContainer from "./ServiceContainer";
+import { zodResolver } from "@hookform/resolvers/zod";
+import {
+  Controller,
+  useFieldArray,
+  useForm,
+  useFormContext,
+} from "react-hook-form";
 import styled from "styled-components";
-import Spacer from "components/porter/Spacer";
+import { z } from "zod";
+
+import Button from "components/porter/Button";
+import Container from "components/porter/Container";
+import { ControlledInput } from "components/porter/ControlledInput";
 import Modal from "components/porter/Modal";
-import Text from "components/porter/Text";
 import Select from "components/porter/Select";
-import Container from "components/porter/Container";
-import Button from "components/porter/Button";
-
-import web from "assets/web.png";
-import worker from "assets/worker.png";
-import job from "assets/job.png";
-import { z } from "zod";
+import Spacer from "components/porter/Spacer";
+import Text from "components/porter/Text";
+import { type PorterAppVersionStatus } from "lib/hooks/useAppStatus";
 import { type PorterAppFormData } from "lib/porter-apps";
 import {
-  type ClientService,
   defaultSerialized,
   deserializeService,
   isPredeployService,
+  type ClientService,
 } from "lib/porter-apps/services";
-import {
-  Controller,
-  useFieldArray,
-  useForm,
-  useFormContext,
-} from "react-hook-form";
-import { ControlledInput } from "components/porter/ControlledInput";
-import { type PorterAppVersionStatus } from "lib/hooks/useAppStatus";
-import { zodResolver } from "@hookform/resolvers/zod";
+
 import { useClusterResources } from "shared/ClusterResourcesContext";
+import job from "assets/job.png";
+import web from "assets/web.png";
+import worker from "assets/worker.png";
+
+import ServiceContainer from "./ServiceContainer";
 
 const addServiceFormValidator = z.object({
   name: z
@@ -72,7 +74,17 @@ const ServiceList: React.FC<ServiceListProps> = ({
   // top level app form
   const { control: appControl } = useFormContext<PorterAppFormData>();
 
-  const { currentClusterResources: { maxCPU, maxRAM, clusterContainsGPUNodes, clusterIngressIp, defaultCPU, defaultRAM } } = useClusterResources();
+  const {
+    currentClusterResources: {
+      maxCPU,
+      maxRAM,
+      clusterContainsGPUNodes,
+      clusterIngressIp,
+      defaultCPU,
+      defaultRAM,
+      loadBalancerType,
+    },
+  } = useClusterResources();
 
   // add service modal form
   const {
@@ -111,9 +123,8 @@ const ServiceList: React.FC<ServiceListProps> = ({
   const serviceType = watch("type");
   const serviceName = watch("name");
 
-  const [showAddServiceModal, setShowAddServiceModal] = useState<boolean>(
-    false
-  );
+  const [showAddServiceModal, setShowAddServiceModal] =
+    useState<boolean>(false);
 
   const services = useMemo(() => {
     // if predeploy, only show predeploy services
@@ -142,11 +153,11 @@ const ServiceList: React.FC<ServiceListProps> = ({
     }
   }, [serviceName, isPredeploy]);
 
-  const isServiceNameDuplicate = (name: string) => {
+  const isServiceNameDuplicate = (name: string): boolean => {
     return services.some(({ svc: s }) => s.name.value === name);
   };
 
-  const maybeRenderAddServicesButton = () => {
+  const maybeRenderAddServicesButton = (): JSX.Element | null => {
     if (
       (isPredeploy && services.find((s) => isPredeployService(s.svc))) ||
       !allowAddServices
@@ -199,7 +210,7 @@ const ServiceList: React.FC<ServiceListProps> = ({
     setShowAddServiceModal(false);
   });
 
-  const onRemove = (index: number) => {
+  const onRemove = (index: number): void => {
     const name = services[index].svc.name.value;
     remove(index);
 
@@ -226,6 +237,7 @@ const ServiceList: React.FC<ServiceListProps> = ({
                 clusterContainsGPUNodes={clusterContainsGPUNodes}
                 internalNetworkingDetails={internalNetworkingDetails}
                 clusterIngressIp={clusterIngressIp}
+                showDisableTls={loadBalancerType === "ALB"}
               />
             ) : null;
           })}
@@ -233,7 +245,12 @@ const ServiceList: React.FC<ServiceListProps> = ({
       )}
       {maybeRenderAddServicesButton()}
       {showAddServiceModal && (
-        <Modal closeModal={() => { setShowAddServiceModal(false); }} width="500px">
+        <Modal
+          closeModal={() => {
+            setShowAddServiceModal(false);
+          }}
+          width="500px"
+        >
           <Text size={16}>{addNewText}</Text>
           <Spacer y={1} />
           <Text color="helper">Select a service type:</Text>
@@ -251,7 +268,9 @@ const ServiceList: React.FC<ServiceListProps> = ({
                 <Select
                   value={serviceType}
                   width="100%"
-                  setValue={(value: string) => { onChange(value); }}
+                  setValue={(value: string) => {
+                    onChange(value);
+                  }}
                   options={[
                     { label: "Web", value: "web" },
                     { label: "Worker", value: "worker" },

+ 67 - 35
dashboard/src/main/home/app-dashboard/validate-apply/services-settings/tabs/Networking.tsx

@@ -1,16 +1,19 @@
 import React, { useMemo } from "react";
-import { ControlledInput } from "components/porter/ControlledInput";
-import Spacer from "components/porter/Spacer";
-import { ClientService, prefixSubdomain } from "lib/porter-apps/services";
 import { Controller, useFormContext } from "react-hook-form";
-import { PorterAppFormData } from "lib/porter-apps";
+import styled from "styled-components";
+
+import CopyToClipboard from "components/CopyToClipboard";
 import Checkbox from "components/porter/Checkbox";
+import { ControlledInput } from "components/porter/ControlledInput";
+import Spacer from "components/porter/Spacer";
 import Text from "components/porter/Text";
+import { type PorterAppFormData } from "lib/porter-apps";
+import { prefixSubdomain, type ClientService } from "lib/porter-apps/services";
+
+import copy from "assets/copy-left.svg";
+
 import CustomDomains from "./CustomDomains";
 import IngressCustomAnnotations from "./IngressCustomAnnotations";
-import styled from "styled-components";
-import CopyToClipboard from "components/CopyToClipboard";
-import copy from "assets/copy-left.svg";
 
 type NetworkingProps = {
   index: number;
@@ -24,13 +27,15 @@ type NetworkingProps = {
     appName: string;
   };
   clusterIngressIp: string;
+  showDisableTls: boolean;
 };
 
-const Networking: React.FC<NetworkingProps> = ({ 
-  index, 
-  service, 
-  internalNetworkingDetails: { namespace, appName }, 
+const Networking: React.FC<NetworkingProps> = ({
+  index,
+  service,
+  internalNetworkingDetails: { namespace, appName },
   clusterIngressIp,
+  showDisableTls,
 }) => {
   const { register, control, watch } = useFormContext<PorterAppFormData>();
 
@@ -41,11 +46,11 @@ const Networking: React.FC<NetworkingProps> = ({
   const internalURL = useMemo(() => {
     if (port) {
       return `http://${appName}-${service.name.value}.${namespace}.svc.cluster.local:${port}`;
-    } 
+    }
     return `http://${appName}-${service.name.value}.${namespace}.svc.cluster.local`;
   }, [service.name.value, namespace, port]);
 
-  const getApplicationURLText = () => {
+  const getApplicationURLText = (): JSX.Element => {
     const numNonEmptyDomains = service.config.domains.filter(
       (d) => d.name.value !== ""
     );
@@ -55,7 +60,12 @@ const Networking: React.FC<NetworkingProps> = ({
           {`External URL${numNonEmptyDomains.length === 1 ? "" : "s"}: `}
           {numNonEmptyDomains.map((d, i) => {
             return (
-              <a href={prefixSubdomain(d.name.value)} target="_blank">
+              <a
+                href={prefixSubdomain(d.name.value)}
+                target="_blank"
+                rel="noreferrer"
+                key={i}
+              >
                 {d.name.value}
                 {i !== numNonEmptyDomains.length - 1 && ", "}
               </a>
@@ -67,8 +77,8 @@ const Networking: React.FC<NetworkingProps> = ({
 
     return (
       <Text color="helper">
-        External URL: Not generated yet. Porter will generate a URL for you
-        on next deploy.
+        External URL: Not generated yet. Porter will generate a URL for you on
+        next deploy.
       </Text>
     );
   };
@@ -86,24 +96,24 @@ const Networking: React.FC<NetworkingProps> = ({
         {...register(`app.services.${index}.port.value`)}
       />
       <Spacer y={0.5} />
-      {namespace && appName &&
+      {namespace && appName && (
         <>
           <Spacer y={0.5} />
           <Text color="helper">
-            Internal URL (for networking between services of this application): 
+            Internal URL (for networking between services of this application):
           </Text>
           <Spacer y={0.5} />
           <IdContainer>
             <Code>{internalURL}</Code>
             <CopyContainer>
-                <CopyToClipboard text={internalURL}>
-                    <CopyIcon src={copy} alt="copy" />
-                </CopyToClipboard>
+              <CopyToClipboard text={internalURL}>
+                <CopyIcon src={copy} alt="copy" />
+              </CopyToClipboard>
             </CopyContainer>
           </IdContainer>
           <Spacer y={0.5} />
         </>
-      }
+      )}
       <Spacer y={0.5} />
       <Controller
         name={`app.services.${index}.config.private.value`}
@@ -133,27 +143,49 @@ const Networking: React.FC<NetworkingProps> = ({
             <a
               href="https://docs.porter.run/standard/deploying-applications/https-and-domains/custom-domains"
               target="_blank"
+              rel="noreferrer"
             >
               &nbsp;(?)
             </a>
           </Text>
           <Spacer y={0.5} />
-          <CustomDomains 
-            index={index} 
-            clusterIngressIp={clusterIngressIp} 
-          />
+          <CustomDomains index={index} clusterIngressIp={clusterIngressIp} />
           <Spacer y={0.5} />
           <Text color="helper">
             Ingress Custom Annotations
             <a
               href="https://docs.porter.run/standard/deploying-applications/runtime-configuration-options/web-applications#ingress-custom-annotations"
               target="_blank"
+              rel="noreferrer"
             >
               &nbsp;(?)
             </a>
           </Text>
           <Spacer y={0.5} />
           <IngressCustomAnnotations index={index} />
+          {showDisableTls && (
+            <>
+              <Spacer y={1} />
+              <Controller
+                name={`app.services.${index}.config.disableTls.value`}
+                control={control}
+                render={({ field: { value, onChange } }) => (
+                  <Checkbox
+                    checked={!value}
+                    disabled={service.config.disableTls?.readOnly}
+                    toggleChecked={() => {
+                      onChange(!value);
+                    }}
+                    disabledTooltip={
+                      "You may only edit this field in your porter.yaml."
+                    }
+                  >
+                    <Text color="helper">Porter TLS Enabled</Text>
+                  </Checkbox>
+                )}
+              />
+            </>
+          )}
         </>
       )}
     </>
@@ -167,15 +199,15 @@ const Code = styled.span`
 `;
 
 const IdContainer = styled.div`
-    background: #26292E;  
-    border-radius: 5px;
-    padding: 10px;
-    display: flex;
-    width: 550px;
-    border-radius: 5px;
-    border: 1px solid ${({ theme }) => theme.border};
-    align-items: center;
-    user-select: text;
+  background: #26292e;
+  border-radius: 5px;
+  padding: 10px;
+  display: flex;
+  width: 550px;
+  border-radius: 5px;
+  border: 1px solid ${({ theme }) => theme.border};
+  align-items: center;
+  user-select: text;
 `;
 
 const CopyContainer = styled.div`

+ 17 - 14
dashboard/src/main/home/app-dashboard/validate-apply/services-settings/tabs/WebTabs.tsx

@@ -1,21 +1,21 @@
 import React from "react";
+import { match } from "ts-pattern";
+
 import TabSelector from "components/TabSelector";
+import { type ClientService } from "lib/porter-apps/services";
 
-import { ClientService } from "lib/porter-apps/services";
-import { match } from "ts-pattern";
-import Networking from "./Networking";
+import Health from "./Health";
 import MainTab from "./Main";
+import Networking from "./Networking";
 import Resources from "./Resources";
-import Health from "./Health";
 
-interface Props {
+type Props = {
   index: number;
   service: ClientService & {
     config: {
       type: "web";
     };
   };
-  chart?: any;
   maxRAM: number;
   maxCPU: number;
   clusterContainsGPUNodes: boolean;
@@ -24,16 +24,18 @@ interface Props {
     appName: string;
   };
   clusterIngressIp: string;
-}
+  showDisableTls: boolean;
+};
 
-const WebTabs: React.FC<Props> = ({ 
-  index, 
-  service, 
-  maxRAM, 
-  maxCPU, 
-  clusterContainsGPUNodes, 
-  internalNetworkingDetails, 
+const WebTabs: React.FC<Props> = ({
+  index,
+  service,
+  maxRAM,
+  maxCPU,
+  clusterContainsGPUNodes,
+  internalNetworkingDetails,
   clusterIngressIp,
+  showDisableTls,
 }) => {
   const [currentTab, setCurrentTab] = React.useState<
     "main" | "resources" | "networking" | "advanced"
@@ -59,6 +61,7 @@ const WebTabs: React.FC<Props> = ({
             service={service}
             internalNetworkingDetails={internalNetworkingDetails}
             clusterIngressIp={clusterIngressIp}
+            showDisableTls={showDisableTls}
           />
         ))
         .with("resources", () => (

+ 27 - 6
dashboard/src/shared/ClusterResourcesContext.tsx

@@ -1,5 +1,10 @@
 import React, { createContext, useContext } from "react";
-import { useClusterResourceLimits } from "lib/hooks/useClusterResourceLimits";
+
+import {
+  useClusterResourceLimits,
+  type ClientLoadBalancerType,
+} from "lib/hooks/useClusterResourceLimits";
+
 import { Context } from "./Context";
 
 export type ClusterResources = {
@@ -9,13 +14,16 @@ export type ClusterResources = {
   defaultRAM: number;
   clusterContainsGPUNodes: boolean;
   clusterIngressIp: string;
+  loadBalancerType: ClientLoadBalancerType;
 };
 
 export const ClusterResourcesContext = createContext<{
   currentClusterResources: ClusterResources;
 } | null>(null);
 
-export const useClusterResources = () => {
+export const useClusterResources = (): {
+  currentClusterResources: ClusterResources;
+} => {
   const context = useContext(ClusterResourcesContext);
   if (context == null) {
     throw new Error(
@@ -25,13 +33,25 @@ export const useClusterResources = () => {
   return context;
 };
 
-const ClusterResourcesProvider = ({ children }: { children: JSX.Element }) => {
+const ClusterResourcesProvider = ({
+  children,
+}: {
+  children: JSX.Element;
+}): JSX.Element => {
   const { currentCluster, currentProject } = useContext(Context);
 
-  const { maxCPU, maxRAM, defaultCPU, defaultRAM, clusterContainsGPUNodes, clusterIngressIp } = useClusterResourceLimits({
+  const {
+    maxCPU,
+    maxRAM,
+    defaultCPU,
+    defaultRAM,
+    clusterContainsGPUNodes,
+    clusterIngressIp,
+    loadBalancerType,
+  } = useClusterResourceLimits({
     projectId: currentProject?.id,
     clusterId: currentCluster?.id,
-    clusterStatus: currentCluster?.status
+    clusterStatus: currentCluster?.status,
   });
 
   return (
@@ -44,6 +64,7 @@ const ClusterResourcesProvider = ({ children }: { children: JSX.Element }) => {
           defaultRAM,
           clusterContainsGPUNodes,
           clusterIngressIp,
+          loadBalancerType,
         },
       }}
     >
@@ -52,4 +73,4 @@ const ClusterResourcesProvider = ({ children }: { children: JSX.Element }) => {
   );
 };
 
-export default ClusterResourcesProvider;
+export default ClusterResourcesProvider;

+ 9 - 0
internal/porter_app/v2/yaml.go

@@ -147,6 +147,7 @@ type Service struct {
 	TimeoutSeconds     int               `yaml:"timeoutSeconds,omitempty" validate:"excluded_unless=Type job"`
 	Private            *bool             `yaml:"private,omitempty" validate:"excluded_unless=Type web"`
 	IngressAnnotations map[string]string `yaml:"ingressAnnotations,omitempty" validate:"excluded_unless=Type web"`
+	DisableTLS         *bool             `yaml:"disableTLS,omitempty" validate:"excluded_unless=Type web"`
 }
 
 // AutoScaling represents the autoscaling settings for web services
@@ -345,6 +346,10 @@ func serviceProtoFromConfig(service Service, serviceType porterv1.ServiceType) (
 			webConfig.Private = service.Private
 		}
 
+		if service.DisableTLS != nil {
+			webConfig.DisableTls = service.DisableTLS
+		}
+
 		serviceProto.Config = &porterv1.Service_WebConfig{
 			WebConfig: webConfig,
 		}
@@ -501,6 +506,10 @@ func appServiceFromProto(service *porterv1.Service) (Service, error) {
 		if webConfig.Private != nil {
 			appService.Private = webConfig.Private
 		}
+
+		if webConfig.DisableTls != nil {
+			appService.DisableTLS = webConfig.DisableTls
+		}
 	case porterv1.ServiceType_SERVICE_TYPE_WORKER:
 		workerConfig := service.GetWorkerConfig()
 		appService.Type = "worker"