Ian Edwards 2 лет назад
Родитель
Сommit
c4b4f7eb68

+ 3 - 1
dashboard/.prettierrc.json

@@ -1 +1,3 @@
-{}
+{
+  "tabWidth": 2
+}

+ 46 - 0
dashboard/package-lock.json

@@ -8,6 +8,7 @@
       "name": "dashboard",
       "version": "0.1.0",
       "dependencies": {
+        "@hookform/resolvers": "^3.1.1",
         "@ironplans/react": "^0.4.0",
         "@loadable/component": "^5.15.2",
         "@material-ui/core": "^4.11.3",
@@ -59,6 +60,7 @@
         "react-diff-viewer": "^3.1.1",
         "react-dom": "^18.0.0",
         "react-error-boundary": "^3.1.3",
+        "react-hook-form": "^7.45.2",
         "react-hot-toast": "^2.4.0",
         "react-infinite-scroll-component": "^6.1.0",
         "react-modal": "^3.11.2",
@@ -71,6 +73,7 @@
         "stacktrace-js": "^2.0.2",
         "styled-components": "^5.2.0",
         "traverse": "^0.6.7",
+        "ts-pattern": "^5.0.4",
         "uuid": "^9.0.0",
         "valtio": "^1.2.4",
         "zod": "^3.20.2"
@@ -2035,6 +2038,14 @@
       "integrity": "sha512-k2Ty1JcVojjJFwrg/ThKi2ujJ7XNLYaFGNB/bWT9wGR+oSMJHMa5w+CUq6p/pVrKeNNgA7pCqEcjSnHVoqJQFw==",
       "dev": true
     },
+    "node_modules/@hookform/resolvers": {
+      "version": "3.1.1",
+      "resolved": "https://registry.npmjs.org/@hookform/resolvers/-/resolvers-3.1.1.tgz",
+      "integrity": "sha512-tS16bAUkqjITNSvbJuO1x7MXbn7Oe8ZziDTJdA9mMvsoYthnOOiznOTGBYwbdlYBgU+tgpI/BtTU3paRbCuSlg==",
+      "peerDependencies": {
+        "react-hook-form": "^7.0.0"
+      }
+    },
     "node_modules/@icons/material": {
       "version": "0.2.4",
       "resolved": "https://registry.npmjs.org/@icons/material/-/material-0.2.4.tgz",
@@ -10946,6 +10957,21 @@
       "resolved": "https://registry.npmjs.org/react-fast-compare/-/react-fast-compare-3.2.1.tgz",
       "integrity": "sha512-xTYf9zFim2pEif/Fw16dBiXpe0hoy5PxcD8+OwBnTtNLfIm3g6WxhKNurY+6OmdH1u6Ta/W/Vl6vjbYP1MFnDg=="
     },
+    "node_modules/react-hook-form": {
+      "version": "7.45.2",
+      "resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.45.2.tgz",
+      "integrity": "sha512-9s45OdTaKN+4NSTbXVqeDITd/nwIg++nxJGL8+OD5uf1DxvhsXQ641kaYHk5K28cpIOTYm71O/fYk7rFaygb3A==",
+      "engines": {
+        "node": ">=12.22.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/react-hook-form"
+      },
+      "peerDependencies": {
+        "react": "^16.8.0 || ^17 || ^18"
+      }
+    },
     "node_modules/react-hot-toast": {
       "version": "2.4.0",
       "resolved": "https://registry.npmjs.org/react-hot-toast/-/react-hot-toast-2.4.0.tgz",
@@ -13020,6 +13046,11 @@
         "node": ">=8"
       }
     },
+    "node_modules/ts-pattern": {
+      "version": "5.0.4",
+      "resolved": "https://registry.npmjs.org/ts-pattern/-/ts-pattern-5.0.4.tgz",
+      "integrity": "sha512-D5iVliqugv2C9541W2CNXFYNEZxr4TiHuLPuf49tKEdQFp/8y8fR0v1RExUvXkiWozKCwE7zv07C6EKxf0lKuQ=="
+    },
     "node_modules/tslib": {
       "version": "1.14.1",
       "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz",
@@ -16423,6 +16454,11 @@
       "integrity": "sha512-k2Ty1JcVojjJFwrg/ThKi2ujJ7XNLYaFGNB/bWT9wGR+oSMJHMa5w+CUq6p/pVrKeNNgA7pCqEcjSnHVoqJQFw==",
       "dev": true
     },
+    "@hookform/resolvers": {
+      "version": "3.1.1",
+      "resolved": "https://registry.npmjs.org/@hookform/resolvers/-/resolvers-3.1.1.tgz",
+      "integrity": "sha512-tS16bAUkqjITNSvbJuO1x7MXbn7Oe8ZziDTJdA9mMvsoYthnOOiznOTGBYwbdlYBgU+tgpI/BtTU3paRbCuSlg=="
+    },
     "@icons/material": {
       "version": "0.2.4",
       "resolved": "https://registry.npmjs.org/@icons/material/-/material-0.2.4.tgz",
@@ -23638,6 +23674,11 @@
       "resolved": "https://registry.npmjs.org/react-fast-compare/-/react-fast-compare-3.2.1.tgz",
       "integrity": "sha512-xTYf9zFim2pEif/Fw16dBiXpe0hoy5PxcD8+OwBnTtNLfIm3g6WxhKNurY+6OmdH1u6Ta/W/Vl6vjbYP1MFnDg=="
     },
+    "react-hook-form": {
+      "version": "7.45.2",
+      "resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.45.2.tgz",
+      "integrity": "sha512-9s45OdTaKN+4NSTbXVqeDITd/nwIg++nxJGL8+OD5uf1DxvhsXQ641kaYHk5K28cpIOTYm71O/fYk7rFaygb3A=="
+    },
     "react-hot-toast": {
       "version": "2.4.0",
       "resolved": "https://registry.npmjs.org/react-hot-toast/-/react-hot-toast-2.4.0.tgz",
@@ -25291,6 +25332,11 @@
         }
       }
     },
+    "ts-pattern": {
+      "version": "5.0.4",
+      "resolved": "https://registry.npmjs.org/ts-pattern/-/ts-pattern-5.0.4.tgz",
+      "integrity": "sha512-D5iVliqugv2C9541W2CNXFYNEZxr4TiHuLPuf49tKEdQFp/8y8fR0v1RExUvXkiWozKCwE7zv07C6EKxf0lKuQ=="
+    },
     "tslib": {
       "version": "1.14.1",
       "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz",

+ 3 - 0
dashboard/package.json

@@ -3,6 +3,7 @@
   "version": "0.1.0",
   "private": true,
   "dependencies": {
+    "@hookform/resolvers": "^3.1.1",
     "@ironplans/react": "^0.4.0",
     "@loadable/component": "^5.15.2",
     "@material-ui/core": "^4.11.3",
@@ -54,6 +55,7 @@
     "react-diff-viewer": "^3.1.1",
     "react-dom": "^18.0.0",
     "react-error-boundary": "^3.1.3",
+    "react-hook-form": "^7.45.2",
     "react-hot-toast": "^2.4.0",
     "react-infinite-scroll-component": "^6.1.0",
     "react-modal": "^3.11.2",
@@ -66,6 +68,7 @@
     "stacktrace-js": "^2.0.2",
     "styled-components": "^5.2.0",
     "traverse": "^0.6.7",
+    "ts-pattern": "^5.0.4",
     "uuid": "^9.0.0",
     "valtio": "^1.2.4",
     "zod": "^3.20.2"

+ 1 - 0
dashboard/src/assets/download.svg

@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#fff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-download"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" x2="12" y1="15" y2="3"/></svg>

+ 37 - 28
dashboard/src/components/image-selector/ImageList.tsx

@@ -11,29 +11,31 @@ import TagList from "./TagList";
 
 type PropsType =
   | {
-    selectedImageUrl: string | null;
-    selectedTag: string | null;
-    clickedImage: ImageType | null;
-    registry?: any;
-    noTagSelection?: boolean;
-    setSelectedImageUrl: (x: string) => void;
-    setSelectedTag: (x: string) => void;
-    setClickedImage: (x: ImageType) => void;
-    disableImageSelect?: boolean;
-    readOnly?: boolean;
-  }
+      selectedImageUrl: string | null;
+      selectedTag: string | null;
+      clickedImage: ImageType | null;
+      registry?: any;
+      noTagSelection?: boolean;
+      setSelectedImageUrl: (x: string) => void;
+      setSelectedTag: (x: string) => void;
+      setClickedImage: (x: ImageType) => void;
+      disableImageSelect?: boolean;
+      readOnly?: boolean;
+      listHeight?: string;
+    }
   | {
-    selectedImageUrl: string | null;
-    selectedTag: string | null;
-    clickedImage: ImageType | null;
-    registry?: any;
-    noTagSelection?: boolean;
-    setSelectedImageUrl?: (x: string) => void;
-    setSelectedTag?: (x: string) => void;
-    setClickedImage?: (x: ImageType) => void;
-    disableImageSelect?: boolean;
-    readOnly: true;
-  };
+      selectedImageUrl: string | null;
+      selectedTag: string | null;
+      clickedImage: ImageType | null;
+      registry?: any;
+      noTagSelection?: boolean;
+      setSelectedImageUrl?: (x: string) => void;
+      setSelectedTag?: (x: string) => void;
+      setClickedImage?: (x: ImageType) => void;
+      disableImageSelect?: boolean;
+      readOnly: true;
+      listHeight?: string;
+    };
 
 type StateType = {
   loading: boolean;
@@ -233,11 +235,16 @@ export default class ImageList extends Component<PropsType, StateType> {
   };
 
   renderExpanded = () => {
-    let { selectedTag, selectedImageUrl, setSelectedTag } = this.props;
+    let {
+      selectedTag,
+      selectedImageUrl,
+      setSelectedTag,
+      listHeight,
+    } = this.props;
 
     if (this.props.readOnly && this.props.clickedImage) {
       return (
-        <ExpandedWrapper>
+        <ExpandedWrapper height={listHeight}>
           <TagList
             selectedTag={selectedTag}
             selectedImageUrl={selectedImageUrl}
@@ -251,7 +258,9 @@ export default class ImageList extends Component<PropsType, StateType> {
     if (!this.props.clickedImage || this.props.noTagSelection) {
       return (
         <div>
-          <ExpandedWrapper>{this.renderImageList()}</ExpandedWrapper>
+          <ExpandedWrapper height={listHeight}>
+            {this.renderImageList()}
+          </ExpandedWrapper>
           {this.renderBackButton()}
         </div>
       );
@@ -259,7 +268,7 @@ export default class ImageList extends Component<PropsType, StateType> {
 
     return (
       <div>
-        <ExpandedWrapper>
+        <ExpandedWrapper height={listHeight}>
           <TagList
             selectedTag={selectedTag}
             selectedImageUrl={selectedImageUrl}
@@ -344,12 +353,12 @@ const LoadingWrapper = styled.div`
   color: #ffffff44;
 `;
 
-const ExpandedWrapper = styled.div`
+const ExpandedWrapper = styled.div<{ height?: string }>`
   margin-top: 10px;
   width: 100%;
   border-radius: 3px;
   border: 1px solid #ffffff44;
-  max-height: 275px;
+  max-height: ${(props) => props.height || "275px"};
   background: #ffffff11;
   overflow-y: auto;
 `;

+ 23 - 19
dashboard/src/components/image-selector/ImageSelector.tsx

@@ -12,25 +12,27 @@ import ImageList from "./ImageList";
 
 type PropsType =
   | {
-    forceExpanded?: boolean;
-    selectedImageUrl: string | null;
-    selectedTag: string | null;
-    setSelectedImageUrl: (x: string) => void;
-    setSelectedTag: (x: string) => void;
-    noTagSelection?: boolean;
-    disableImageSelect?: boolean;
-    readOnly?: boolean;
-  }
+      forceExpanded?: boolean;
+      selectedImageUrl: string | null;
+      selectedTag: string | null;
+      setSelectedImageUrl: (x: string) => void;
+      setSelectedTag: (x: string) => void;
+      noTagSelection?: boolean;
+      disableImageSelect?: boolean;
+      readOnly?: boolean;
+      listHeight?: string;
+    }
   | {
-    forceExpanded?: boolean;
-    selectedImageUrl: string | null;
-    selectedTag: string | null;
-    setSelectedImageUrl?: (x: string) => void;
-    setSelectedTag?: (x: string) => void;
-    noTagSelection?: boolean;
-    disableImageSelect?: boolean;
-    readOnly: true;
-  };
+      forceExpanded?: boolean;
+      selectedImageUrl: string | null;
+      selectedTag: string | null;
+      setSelectedImageUrl?: (x: string) => void;
+      setSelectedTag?: (x: string) => void;
+      noTagSelection?: boolean;
+      disableImageSelect?: boolean;
+      readOnly: true;
+      listHeight?: string;
+    };
 
 type StateType = {
   isExpanded: boolean;
@@ -154,6 +156,7 @@ export default class ImageSelector extends Component<PropsType, StateType> {
               this.setState({ clickedImage: x })
             }
             readOnly
+            listHeight={this.props.listHeight}
           />
         </>
       );
@@ -186,6 +189,7 @@ export default class ImageSelector extends Component<PropsType, StateType> {
             setClickedImage={(x: ImageType) =>
               this.setState({ clickedImage: x })
             }
+            listHeight={this.props.listHeight}
           />
         ) : null}
       </div>
@@ -243,7 +247,7 @@ const ImageItem = styled.div`
   font-size: 13px;
   border-bottom: 1px solid
     ${(props: { lastItem: boolean; isSelected: boolean }) =>
-    props.lastItem ? "#00000000" : "#606166"};
+      props.lastItem ? "#00000000" : "#606166"};
   color: #ffffff;
   user-select: none;
   align-items: center;

+ 120 - 0
dashboard/src/components/porter/ControlledInput.tsx

@@ -0,0 +1,120 @@
+import React from "react";
+import styled from "styled-components";
+
+export const ControlledInput = React.forwardRef<
+    HTMLInputElement,
+    {
+        id: string;
+        name: string;
+        label?: string;
+        type: React.HTMLInputTypeAttribute;
+        autoComplete: string;
+        placeholder?: string;
+        defaultValue?: string;
+        disabled?: boolean;
+        onChange: React.ChangeEventHandler<HTMLInputElement>;
+        onBlur: React.FocusEventHandler<HTMLInputElement>;
+        width?: string;
+        height?: string;
+        error?: string;
+    }
+>(
+    (
+        {
+            id,
+            name,
+            label,
+            type,
+            autoComplete,
+            placeholder,
+            defaultValue,
+            disabled,
+            onChange,
+            onBlur,
+            width,
+            height,
+            error,
+        },
+        ref
+    ) => (
+        <Block width={width}>
+            {label && <Label>{label}</Label>}
+            <StyledInput
+                id={id}
+                name={name}
+                type={type}
+                autoComplete={autoComplete}
+                placeholder={placeholder}
+                defaultValue={defaultValue}
+                onChange={onChange}
+                onBlur={onBlur}
+                ref={ref}
+                disabled={disabled}
+                width={width}
+                height={height}
+                hasError={(error && true) || error === ""}
+            />
+            {error && (
+                <Error>
+                    <i className="material-icons">error</i>
+                    {error}
+                </Error>
+            )}
+        </Block>
+    )
+);
+
+ControlledInput.displayName = "ControlledInput";
+
+const Block = styled.div<{
+    width?: string;
+}>`
+    display: block;
+    position: relative;
+    width: ${(props) => props.width || "200px"};
+`;
+
+const Label = styled.div`
+    font-size: 13px;
+    color: #aaaabb;
+    margin-bottom: 10px;
+`;
+
+const Error = styled.div`
+    display: flex;
+    align-items: center;
+    font-size: 13px;
+    color: #ff3b62;
+    margin-top: 10px;
+
+    > i {
+        font-size: 18px;
+        margin-right: 5px;
+    }
+`;
+
+const StyledInput = styled.input<{
+    hasError: boolean;
+    width?: string;
+    height?: string;
+    disabled?: boolean;
+}>`
+    height: ${(props) => props.height || "35px"};
+    padding: 5px 10px;
+    width: ${(props) => props.width || "200px"};
+    color: ${(props) => (props.disabled ? "#aaaabb" : "#ffffff")};
+    font-size: 13px;
+    outline: none;
+    border-radius: 5px;
+    background: #26292e;
+    cursor: ${(props) => (props.disabled ? "not-allowed" : "")};
+
+    border: 1px solid ${(props) => (props.hasError ? "#ff3b62" : "#494b4f")};
+    ${(props) =>
+        !props.disabled &&
+        `
+      :hover {
+        border: 1px solid ${props.hasError ? "#ff3b62" : "#7a7b80"};
+      }
+    `}
+`;

+ 382 - 0
dashboard/src/lib/charts.ts

@@ -0,0 +1,382 @@
+import { z } from "zod";
+
+const dependencyValidator = z.object({
+  name: z.string(),
+  version: z.string(),
+  repository: z.string(),
+  enabled: z.boolean(),
+  alias: z.string(),
+});
+
+const autoScalingValidator = z.object({
+  enabled: z.boolean().default(false),
+  minReplicas: z.coerce.number().default(1),
+  maxReplicas: z.coerce.number().default(10),
+  targetCPUUtilizationPercentage: z.coerce.number().default(50),
+  targetMemoryUtilizationPercentage: z.coerce.number().default(50),
+});
+
+const cloudsqlValidator = z.object({
+  connectionName: z.string(),
+  dbPort: z.coerce.number().default(5432),
+  enabled: z.boolean().default(false),
+  serviceAccountJSON: z.string(),
+});
+
+const baseChartValidator = z.object({
+  container: z.object({
+    command: z.string().nullable().default(null),
+    env: z.object({
+      normal: z.record(z.string(), z.string()).nullable().default(null),
+    }),
+    port: z.coerce.number().default(80),
+  }),
+  global: z.object({
+    image: z.object({
+      repository: z.string(),
+      tag: z.string(),
+    }),
+  }),
+  hostAliases: z.array(z.unknown()),
+  image: z.object({
+    pullPolicy: z.string(),
+    repository: z.string(),
+    tag: z.string(),
+  }),
+  nodeSelector: z.object({}),
+  resources: z.object({
+    requests: z.object({
+      cpu: z.string(),
+      memory: z.string(),
+    }),
+  }),
+  replicaCount: z.coerce.number().default(1),
+  serviceAccount: z.object({
+    create: z.boolean().default(false),
+    name: z.string(),
+    annotations: z.object({}),
+  }),
+  stack: z.object({
+    enabled: z.boolean().default(false),
+    name: z.string(),
+    revision: z.number().default(0),
+  }),
+  terminationGracePeriodSeconds: z.number().default(30),
+  tolerations: z.array(z.unknown()),
+});
+
+export const jobChartValidator = baseChartValidator.extend({
+  allowConcurrency: z.boolean().default(true),
+  auto_deploy: z.boolean().default(true),
+  cloudsql: cloudsqlValidator,
+  paused: z.boolean().default(false),
+  retainFailedHooks: z.boolean().default(false),
+  schedule: z.object({
+    enabled: z.boolean().default(false),
+    failedHistory: z.number().default(20),
+    successfulHistory: z.number().default(20),
+    value: z.string(),
+  }),
+  sidecar: z
+    .object({
+      resources: z.object({
+        requests: z.object({
+          cpu: z.string(),
+          memory: z.string(),
+        }),
+      }),
+    })
+    .optional(),
+});
+
+export type JobChart = z.infer<typeof jobChartValidator>;
+
+const workerHealthProbeValidator = z.object({
+  command: z.string().default("ls -l"),
+  enabled: z.boolean().default(false),
+  failureThreshold: z.coerce.number().default(3),
+  initialDelaySeconds: z.number().default(5),
+  periodSeconds: z.number().default(5),
+});
+
+export const workerChartValidator = baseChartValidator.extend({
+  autoscaling: autoScalingValidator,
+  cloudsql: cloudsqlValidator,
+  emptyDir: z.object({
+    enabled: z.boolean().default(false),
+    mountPath: z.string().default("/mypath"),
+  }),
+  health: z.object({
+    command: z.string().default("ls -l"),
+    enabled: z.boolean().default(false),
+    failureThreshold: z.coerce.number().default(3),
+    periodSeconds: z.number().default(5),
+    livenessProbe: workerHealthProbeValidator,
+    readinessProbe: workerHealthProbeValidator,
+    startupProbe: workerHealthProbeValidator,
+  }),
+  keda: z.object({
+    cooldownPeriod: z.number().default(300),
+    enabled: z.boolean().default(false),
+    fallback: z.object({
+      failureReplicas: z.coerce.number().default(6),
+      failureThreshold: z.coerce.number().default(3),
+    }),
+    hpa: z.object({
+      scaleDown: z.object({
+        policy: z.object({
+          periodSeconds: z.number().default(300),
+          value: z.number().default(10),
+        }),
+        stabilizationWindowSeconds: z.number().default(300),
+      }),
+      scaleUp: z.object({
+        policy: z.object({
+          periodSeconds: z.number().default(300),
+          value: z.number().default(10),
+        }),
+        stabilizationWindowSeconds: z.number().default(300),
+      }),
+    }),
+    maxReplicaCount: z.number().default(10),
+    minReplicaCount: z.number().default(1),
+    pollingInterval: z.number().default(30),
+    trigger: z.object({
+      metricName: z.string(),
+      metricQuery: z.string(),
+      metricThreshold: z.string(),
+    }),
+    triggers: z.array(z.unknown()),
+  }),
+  podLabels: z.object({}),
+  pvc: z.object({
+    enabled: z.boolean().default(false),
+    mountPath: z.string().default("/mypath"),
+    storage: z.string().default("20Gi"),
+  }),
+  topology: z.object({
+    enabled: z.boolean().default(false),
+    labelSelector: z.object({
+      enabled: z.boolean().default(false),
+      matchLabels: z.object({}),
+    }),
+  }),
+});
+
+export type WorkerChart = z.infer<typeof workerChartValidator>;
+
+const webHealthProbeValidator = z.object({
+  auth: z.object({
+    enabled: z.boolean().default(false),
+    password: z.string(),
+    username: z.string(),
+  }),
+  enabled: z.boolean().default(false),
+  failureThreshold: z.coerce.number().default(3),
+  httpHeaders: z.array(z.unknown()),
+  initialDelaySeconds: z.number().default(0),
+  periodSeconds: z.number().default(5),
+  scheme: z.string().default("HTTP"),
+  timeoutSeconds: z.number().default(1),
+});
+
+const webChartValidator = baseChartValidator.extend({
+  albIngress: z.object({
+    custom_paths: z.array(z.unknown()),
+    enabled: z.boolean().default(false),
+    hosts: z.array(z.string()),
+    scheme: z.string().default("internet-facing"),
+    target_type: z.string().default("ip"),
+  }),
+  autoscaling: autoScalingValidator,
+  auto_deploy: z.boolean().default(true),
+  bluegreen: z.object({
+    disablePrimaryDeployment: z.boolean().default(false),
+    enabled: z.boolean().default(false),
+    imageTags: z.array(z.string()),
+  }),
+  cloudsql: cloudsqlValidator,
+  customNodePort: z.object({
+    enabled: z.boolean().default(false),
+    port: z.number().default(30000),
+  }),
+  datadog: z.object({
+    enabled: z.boolean().default(false),
+  }),
+  emptyDir: z.object({
+    enabled: z.boolean().default(false),
+    mountPath: z.string().default("/mypath"),
+  }),
+  health: z.object({
+    livenessCommand: z.object({
+      command: z.string().default("ls -l"),
+      enabled: z.boolean().default(false),
+      failureThreshold: z.coerce.number().default(3),
+      initialDelaySeconds: z.number().default(5),
+      periodSeconds: z.number().default(5),
+      successThreshold: z.number().default(1),
+      timeoutSeconds: z.number().default(1),
+    }),
+    livenessProbe: webHealthProbeValidator.extend({
+      initialDelaySeconds: z.number().default(0),
+      path: z.string().default("/livez"),
+      successThreshold: z.number().default(1),
+    }),
+    readinessProbe: webHealthProbeValidator.extend({
+      initialDelaySeconds: z.number().default(0),
+      path: z.string().default("/readyz"),
+      successThreshold: z.number().default(1),
+    }),
+    startupProbe: webHealthProbeValidator.extend({
+      path: z.string().default("/readyz"),
+    }),
+  }),
+  ingress: z.object({
+    annotations: z.object({}),
+    customTls: z.object({
+      enabled: z.boolean().default(false),
+    }),
+    custom_domain: z.boolean().default(false),
+    custom_paths: z.array(z.unknown()),
+    enabled: z.boolean().default(false),
+    hosts: z.array(z.string()),
+    porter_hosts: z.array(z.string()),
+    provider: z.string().default("aws"),
+    rewriteCustomPathsEnabled: z.boolean().default(true),
+    tls: z.boolean().default(true),
+    useDefaultIngressTLSSecret: z.boolean().default(false),
+    wildcard: z.boolean().default(false),
+  }),
+  keda: z.object({
+    cooldownPeriod: z.number().default(300),
+    enabled: z.boolean().default(false),
+    fallback: z.object({
+      failureReplicas: z.coerce.number().default(6),
+      failureThreshold: z.coerce.number().default(3),
+    }),
+    hpa: z.object({
+      scaleDown: z.object({
+        policy: z.object({
+          periodSeconds: z.number().default(300),
+          type: z.string().default("Percent"),
+          value: z.number().default(10),
+        }),
+        stabilizationWindowSeconds: z.number().default(300),
+      }),
+      scaleUp: z.object({
+        policy: z.object({
+          periodSeconds: z.number().default(300),
+          type: z.string().default("Percent"),
+          value: z.number().default(10),
+        }),
+        stabilizationWindowSeconds: z.number().default(300),
+      }),
+    }),
+    maxReplicaCount: z.number().default(10),
+    minReplicaCount: z.number().default(1),
+    pollingInterval: z.number().default(30),
+    trigger: z.object({
+      metricName: z.string(),
+      metricQuery: z.string(),
+      metricThreshold: z.string(),
+      metricType: z.string().default("AverageValue"),
+    }),
+    triggers: z.array(z.unknown()),
+  }),
+  podLabels: z.object({}),
+  privateIngress: z.object({
+    annotations: z.object({}),
+    clusterIssuer: z.string().default("letsencrypt-prod-private"),
+    custom_paths: z.array(z.unknown()),
+    enabled: z.boolean().default(false),
+    hosts: z.array(z.unknown()),
+    tls: z.boolean().default(false),
+  }),
+  pvc: z.object({
+    enabled: z.boolean().default(false),
+    existingVolume: z.string(),
+    mountPath: z.string().default("/mypath"),
+    storage: z.string().default("20Gi"),
+  }),
+  service: z.object({ port: z.number().default(80) }),
+
+  stack: z.object({
+    enabled: z.boolean(),
+    name: z.string(),
+    revision: z.number(),
+  }),
+  statefulset: z.object({ enabled: z.boolean() }),
+  terminationGracePeriodSeconds: z.number(),
+  tolerations: z.array(z.unknown()),
+  topology: z.object({
+    enabled: z.boolean().default(false),
+    labelSelector: z.object({
+      enabled: z.boolean().default(false),
+      matchLabels: z.object({}),
+    }),
+  }),
+});
+
+export type WebChart = z.infer<typeof webChartValidator>;
+
+export const umbrellaChartValidator = z.object({
+  name: z.string(),
+  info: z.object({
+    first_deployed: z.string().datetime(),
+    last_deployed: z.string().datetime(),
+    deleted: z.string(),
+    description: z.string(),
+    status: z.string(),
+  }),
+  chart: z.object({
+    metadata: z.object({
+      name: z.string(),
+      home: z.string().url(),
+      version: z.string(),
+      description: z.string(),
+      keywords: z.array(z.string()),
+      icon: z.string().url(),
+      apiVersion: z.string(),
+      dependencies: z.array(dependencyValidator),
+      type: z.string(),
+    }),
+    values: z.record(
+      z.string(),
+      z.union([jobChartValidator, webChartValidator, workerChartValidator])
+    ),
+  }),
+  config: z.record(
+    z.string(),
+    z.union([
+      jobChartValidator.deepPartial(),
+      webChartValidator.deepPartial(),
+      workerChartValidator.deepPartial(),
+    ])
+  ),
+  manifest: z.string(),
+  hooks: z.array(
+    z.object({
+      events: z.array(z.string()),
+      name: z.string(),
+      weight: z.number().optional(),
+      manifest: z.string(),
+      path: z.string(),
+      last_run: z.object({
+        started_at: z.string(),
+        completed_at: z.string(),
+        phase: z.string(),
+      }),
+      delete_policies: z.array(z.string()).optional(),
+    })
+  ),
+  version: z.number(),
+  namespace: z.string(),
+  id: z.number(),
+  webhook_token: z.string(),
+  latest_version: z.string(),
+  image_repo_uri: z.string(),
+  stack_id: z.string(),
+  canonical_name: z.string(),
+});
+
+export type UmbrellaChart = z.infer<typeof umbrellaChartValidator>;

+ 30 - 11
dashboard/src/main/home/app-dashboard/new-app-flow/SourceSelector.tsx

@@ -1,22 +1,37 @@
 import React from "react";
 import styled from "styled-components";
+import download from "assets/download.svg";
 
-export type SourceType = "github" | "docker-registry";
+export type SourceType = "github" | "docker-registry" | "existing";
 
 interface SourceSelectorProps {
   selectedSourceType: SourceType | undefined;
   setSourceType: (sourceType: SourceType) => void;
+  allowExisting?: boolean;
 }
 
 const SourceSelector: React.FC<SourceSelectorProps> = ({
   selectedSourceType,
-  setSourceType
+  setSourceType,
+  allowExisting,
 }) => {
   return (
     <BlockList>
+      {allowExisting && (
+        <Block
+          selected={selectedSourceType === "existing"}
+          onClick={() => setSourceType("existing")}
+        >
+          <BlockIcon src={download} />
+          <BlockTitle>Import existing</BlockTitle>
+          <BlockDescription>
+            Use one of your existing apps on Porter.
+          </BlockDescription>
+        </Block>
+      )}
       <Block
-        selected={selectedSourceType === 'github'}
-        onClick={() => setSourceType('github')}
+        selected={selectedSourceType === "github"}
+        onClick={() => setSourceType("github")}
       >
         <BlockIcon src="https://git-scm.com/images/logos/downloads/Git-Icon-1788C.png" />
         <BlockTitle>Git repository</BlockTitle>
@@ -25,8 +40,8 @@ const SourceSelector: React.FC<SourceSelectorProps> = ({
         </BlockDescription>
       </Block>
       <Block
-        selected={selectedSourceType === 'docker-registry'}
-        onClick={() => setSourceType('docker-registry')}
+        selected={selectedSourceType === "docker-registry"}
+        onClick={() => setSourceType("docker-registry")}
       >
         <BlockIcon src="https://cdn4.iconfinder.com/data/icons/logos-and-brands/512/97_Docker_logo_logos-512.png" />
         <BlockTitle>Docker registry</BlockTitle>
@@ -34,10 +49,9 @@ const SourceSelector: React.FC<SourceSelectorProps> = ({
           Deploy a container from an image registry.
         </BlockDescription>
       </Block>
-
     </BlockList>
   );
-}
+};
 
 export default SourceSelector;
 
@@ -58,10 +72,11 @@ const Block = styled.div<{ selected?: boolean }>`
   position: relative;
 
   border-radius: 5px;
-  background: ${props => props.theme.clickable.bg};
-  border: ${props => props.selected ? "2px solid #8590ff" : "1px solid #494b4f"};
+  background: ${(props) => props.theme.clickable.bg};
+  border: ${(props) =>
+    props.selected ? "2px solid #8590ff" : "1px solid #494b4f"};
   :hover {
-    border: ${({ selected }) => (!selected && "1px solid #7a7b80")};
+    border: ${({ selected }) => !selected && "1px solid #7a7b80"};
   }
 
   animation: fadeIn 0.3s 0s;
@@ -90,6 +105,10 @@ const BlockIcon = styled.img<{ bw?: boolean }>`
   margin-top: 30px;
   margin-bottom: 15px;
   filter: ${(props) => (props.bw ? "grayscale(1)" : "")};
+
+  .lucide-download {
+    color: #ffffff;
+  }
 `;
 
 const BlockDescription = styled.div`

Разница между файлами не показана из-за своего большого размера
+ 680 - 232
dashboard/src/main/home/app-dashboard/new-app-flow/serviceTypes.ts


+ 19 - 2
dashboard/src/main/home/app-dashboard/types/porterApp.ts

@@ -1,3 +1,20 @@
+import { z } from "zod";
+
+export const porterAppValidator = z.object({
+    name: z.string().default(""),
+    git_branch: z.string().default(""),
+    git_repo_id: z.number().default(0),
+    repo_name: z.string().default(""),
+    build_context: z.string().default("./"),
+    builder: z.string().default(""),
+    buildpacks: z
+        .preprocess((val) => String(val).split(","), z.array(z.string()))
+        .default([]),
+    dockerfile: z.string().default(""),
+    image_repo_uri: z.string().default(""),
+    porter_yaml_path: z.string().default(""),
+});
+
 export interface PorterApp {
     name: string;
     git_branch: string;
@@ -38,6 +55,6 @@ export const PorterApp = {
         ...app,
         ...values,
     }),
-}
+};
 
-export type BuildMethod = "docker" | "buildpacks";
+export type BuildMethod = "docker" | "buildpacks";

+ 162 - 0
dashboard/src/main/home/cluster-dashboard/preview-environments/ConfigureEnvironment.tsx

@@ -0,0 +1,162 @@
+import React, { useContext, useEffect, useState } from "react";
+import styled from "styled-components";
+import { Context } from "shared/Context";
+import Back from "components/porter/Back";
+import DashboardHeader from "../DashboardHeader";
+import PullRequestIcon from "assets/pull_request_icon.svg";
+import { Controller, FormProvider, useForm } from "react-hook-form";
+import { z } from "zod";
+import { serviceValidator } from "main/home/app-dashboard/new-app-flow/serviceTypes";
+import VerticalSteps from "components/porter/VerticalSteps";
+import Text from "components/porter/Text";
+import Spacer from "components/porter/Spacer";
+import CheckboxRow from "components/form-components/CheckboxRow";
+import Helper from "components/form-components/Helper";
+import { zodResolver } from "@hookform/resolvers/zod";
+import Button from "components/porter/Button";
+import EnvironmentApps from "./create-env/EnvironmentApps";
+import { porterAppValidator } from "main/home/app-dashboard/types/porterApp";
+import { PorterYamlSchema } from "main/home/app-dashboard/new-app-flow/schema";
+
+type ConfigureEnvironmentProps = {};
+
+const formData = z.object({
+  name: z.string().default("preview"),
+  clusterID: z.number(),
+  projectID: z.number(),
+  gitInstallationID: z.number(),
+  auto: z.boolean().default(false),
+  applications: z
+    .array(
+      porterAppValidator.extend({
+        baseYaml: PorterYamlSchema.optional(),
+        services: z.array(serviceValidator),
+        envVariables: z.array(
+          z.object({
+            key: z.string(),
+            value: z.string(),
+            hidden: z.boolean(),
+            locked: z.boolean(),
+            deleted: z.boolean(),
+          })
+        ),
+      })
+    )
+    .refine((apps) => new Set(apps.map((a) => a.name)).size === apps.length, {
+      message: "Duplicate app names are not allowed",
+    }),
+});
+export type FormData = z.infer<typeof formData>;
+
+export const ConfigureEnvironment: React.FC<ConfigureEnvironmentProps> = ({}) => {
+  const { currentProject, currentCluster } = useContext(Context);
+  const methods = useForm<FormData>({
+    reValidateMode: "onSubmit",
+    resolver: zodResolver(formData),
+    defaultValues: {
+      name: "preview",
+      clusterID: currentCluster?.id,
+      projectID: currentProject?.id,
+    },
+  });
+  const { watch, control } = methods;
+
+  const [metaStep, setMetaStep] = useState(1);
+
+  const name = watch("name");
+
+  useEffect(() => {
+    if (name?.length) {
+      setMetaStep(2);
+    }
+  }, [name]);
+
+  if (!currentProject || !currentCluster) {
+    return null;
+  }
+
+  return (
+    <FormProvider {...methods}>
+      <CenterWrapper>
+        <MainForm>
+          <Back to="/preview-environments" />
+          <DashboardHeader
+            image={PullRequestIcon}
+            title="Preview environments"
+            capitalize={false}
+            description="Create full-stack preview environments for your pull requests."
+          />
+          <VerticalSteps
+            currentStep={metaStep}
+            steps={[
+              <>
+                <Text size={16}>Applications</Text>
+                <Spacer y={0.5} />
+                <Text color="helper">
+                  Include existing or new applications in your preview
+                  environment.
+                </Text>
+                <Spacer y={0.5} />
+                <EnvironmentApps
+                  projectId={currentProject.id}
+                  clusterId={currentCluster.id}
+                />
+              </>,
+              <>
+                <Text size={16}>Automatic pull request deployments</Text>
+                <Helper style={{ marginTop: "10px", marginBottom: "10px" }}>
+                  If you enable this option, the new pull requests will be
+                  automatically deployed.
+                </Helper>
+                <CheckboxWrapper>
+                  <Controller
+                    control={control}
+                    name="auto"
+                    render={({ field: { onChange, value } }) => (
+                      <CheckboxRow
+                        label="Enable automatic deploys"
+                        checked={value}
+                        toggle={() => {
+                          onChange(!value);
+                          setMetaStep(2);
+                        }}
+                        wrapperStyles={{
+                          disableMargin: true,
+                        }}
+                      />
+                    )}
+                  />
+                </CheckboxWrapper>
+              </>,
+              <Button
+                disabled={!Boolean(name?.length)}
+                onClick={() => {}}
+                width={"120px"}
+              >
+                Continue
+              </Button>,
+            ]}
+          />
+        </MainForm>
+      </CenterWrapper>
+    </FormProvider>
+  );
+};
+
+const CenterWrapper = styled.div`
+  width: 100%;
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+`;
+
+const MainForm = styled.div`
+  width: 100%;
+  max-width: 900px;
+`;
+
+const CheckboxWrapper = styled.div`
+  display: flex;
+  align-items: center;
+  margin-top: 20px;
+`;

+ 297 - 0
dashboard/src/main/home/cluster-dashboard/preview-environments/create-env/AppBlock.tsx

@@ -0,0 +1,297 @@
+import React, { useMemo, useState } from "react";
+import styled from "styled-components";
+import web from "assets/web.png";
+
+import Icon from "components/porter/Icon";
+import { FormData } from "../ConfigureEnvironment";
+import AnimateHeight from "react-animate-height";
+import Text from "components/porter/Text";
+import Spacer from "components/porter/Spacer";
+import { Controller, useFormContext } from "react-hook-form";
+import BranchSelector from "main/home/app-dashboard/build-settings/BranchSelector";
+import Input from "components/porter/Input";
+import { BackButton } from "./EnvironmentApps";
+import { ControlledInput } from "components/porter/ControlledInput";
+import Services from "main/home/app-dashboard/new-app-flow/Services";
+import { Service } from "main/home/app-dashboard/new-app-flow/serviceTypes";
+
+import DetectDockerfileAndPorterYaml from "main/home/app-dashboard/build-settings/DetectDockerfileAndPorterYaml";
+import AdvancedBuildSettings from "main/home/app-dashboard/build-settings/AdvancedBuildSettings";
+import { PorterApp } from "main/home/app-dashboard/types/porterApp";
+
+type AppBlockProps = {
+  app: FormData["applications"][0];
+  remove: (idx: number) => void;
+  idx: number;
+};
+
+export const AppBlock: React.FC<AppBlockProps> = ({ app, remove, idx }) => {
+  const { register, control } = useFormContext<FormData>();
+  const [showExpanded, setShowExpanded] = useState<boolean>(true);
+  const [buildView, setBuildView] = useState<"docker" | "buildpacks">(
+    "buildpacks"
+  );
+
+  console.log("app.baseYaml", app.baseYaml);
+
+  return (
+    <div>
+      <AppHeader
+        showExpanded={showExpanded}
+        onClick={() => {
+          setShowExpanded((prev) => !prev);
+        }}
+        bordersRounded={!showExpanded}
+      >
+        <AppTitle>
+          <ActionButton>
+            <span className="material-icons dropdown">arrow_drop_down</span>
+          </ActionButton>
+          <Icon src={web} />
+          <p>{app.name}</p>
+        </AppTitle>
+        <ActionButton onClick={() => remove(idx)}>
+          <span className="material-icons">delete</span>
+        </ActionButton>
+      </AppHeader>
+      <AnimateHeight height={showExpanded ? "auto" : 0}>
+        <StyledSourceBox>
+          <Controller
+            control={control}
+            name={`applications.${idx}`}
+            render={({ field: { value, onChange } }) => (
+              <>
+                {value.repo_name !== "" ? (
+                  <>
+                    <Text size={16}>Build settings</Text>
+                    <Spacer y={0.5} />
+                    <Text color="helper">Specify your GitHub branch.</Text>
+                    <Spacer y={0.5} />
+                    {value.git_branch === "" ? (
+                      <BranchSelector
+                        setBranch={(branch: string) => {
+                          onChange((prev: Partial<PorterApp>) => ({
+                            ...prev,
+                            git_branch: branch,
+                          }));
+                        }}
+                        repo_name={value.repo_name}
+                        git_repo_id={value.git_repo_id}
+                      />
+                    ) : (
+                      <>
+                        <Input
+                          disabled={true}
+                          label="GitHub branch:"
+                          type="text"
+                          width="100%"
+                          value={value.git_branch}
+                          setValue={() => {}}
+                          placeholder=""
+                        />
+                        <BackButton
+                          width="145px"
+                          onClick={() => {
+                            onChange((prev: Partial<PorterApp>) => ({
+                              ...prev,
+                              git_branch: "",
+                            }));
+                          }}
+                        >
+                          <i className="material-icons">keyboard_backspace</i>
+                          Select branch
+                        </BackButton>
+                        <Spacer y={1} />
+                        <Text color="helper">
+                          Specify your application root path.
+                        </Text>
+                        <Spacer y={0.5} />
+                        <ControlledInput
+                          id={`applications.${idx}.build_context`}
+                          placeholder="ex: ./"
+                          autoComplete="off"
+                          type="text"
+                          {...register(`applications.${idx}.build_context`)}
+                        />
+                        <Spacer y={0.5} />
+                        <Controller
+                          control={control}
+                          name={`applications.${idx}`}
+                          render={({ field }) => (
+                            <>
+                              <DetectDockerfileAndPorterYaml
+                                setPorterYaml={() => {}}
+                                porterApp={field.value}
+                                updatePorterApp={(app) => {
+                                  field.onChange(
+                                    (prev: Partial<PorterApp>) => ({
+                                      ...prev,
+                                      ...app,
+                                    })
+                                  );
+                                }}
+                                updateDockerfileFound={() =>
+                                  setBuildView("docker")
+                                }
+                              />
+                              <AdvancedBuildSettings
+                                porterApp={field.value}
+                                updatePorterApp={(app) => {
+                                  field.onChange(
+                                    (prev: Partial<PorterApp>) => ({
+                                      ...prev,
+                                      ...app,
+                                    })
+                                  );
+                                }}
+                                autoDetectBuildpacks={true}
+                                buildView={buildView}
+                                setBuildView={setBuildView}
+                              />
+                            </>
+                          )}
+                        />
+                        <Spacer y={1} />
+                      </>
+                    )}
+                  </>
+                ) : null}
+              </>
+            )}
+          />
+          <Text size={16}>Application services</Text>
+          <Spacer y={0.5} />
+          <Controller
+            control={control}
+            name={`applications.${idx}.services`}
+            render={({ field: { value: services, onChange } }) => (
+              <Services
+                setServices={(svcs: Service[]) => {
+                  const release = svcs.filter(Service.isRelease);
+                  const newServices = [...release, ...svcs];
+                  onChange(newServices);
+                }}
+                services={services}
+                addNewText={"Add a new service"}
+                setExpandedJob={(x: string) => {}}
+              />
+            )}
+          />
+          {!app.image_repo_uri && (
+            <Controller
+              control={control}
+              name={`applications.${idx}.services`}
+              render={({ field: { value: services, onChange } }) => (
+                <>
+                  <Spacer y={1} />
+                  <Text size={16}>Pre-deploy job</Text>
+                  <Spacer y={0.5} />
+                  <Services
+                    setServices={(release: Service[]) => {
+                      const nonRelease = services.filter(Service.isNonRelease);
+                      const newServices = [...nonRelease, ...release];
+                      onChange(newServices);
+                    }}
+                    services={services.filter(Service.isRelease)}
+                    limitOne={true}
+                    addNewText={"Add a new pre-deploy job"}
+                    defaultExpanded={false}
+                    prePopulateService={Service.default(
+                      "pre-deploy",
+                      "release",
+                      app.baseYaml
+                    )}
+                  />
+                  <Spacer y={0.5} />
+                </>
+              )}
+            />
+          )}
+        </StyledSourceBox>
+      </AnimateHeight>
+    </div>
+  );
+};
+
+const AppHeader = styled.div<{
+  showExpanded: boolean;
+  bordersRounded?: boolean;
+}>`
+  flex-direction: row;
+  display: flex;
+  height: 60px;
+  justify-content: space-between;
+  cursor: pointer;
+  padding: 20px;
+  color: ${(props) => props.theme.text.primary};
+  position: relative;
+  border-radius: 5px;
+  background: ${(props) => props.theme.clickable.bg};
+  border: 1px solid #494b4f;
+  :hover {
+    border: 1px solid #7a7b80;
+  }
+
+  border-bottom-left-radius: ${(props) => (props.bordersRounded ? "" : "0")};
+  border-bottom-right-radius: ${(props) => (props.bordersRounded ? "" : "0")};
+
+  .dropdown {
+    font-size: 30px;
+    cursor: pointer;
+    border-radius: 20px;
+    margin-left: -10px;
+    transform: ${(props: { showExpanded: boolean }) =>
+      props.showExpanded ? "" : "rotate(-90deg)"};
+  }
+
+  animation: fadeIn 0.3s 0s;
+  @keyframes fadeIn {
+    from {
+      opacity: 0;
+    }
+    to {
+      opacity: 1;
+    }
+  }
+`;
+
+const AppTitle = styled.div`
+  display: flex;
+  align-items: center;
+  column-gap: 10px;
+`;
+
+const ActionButton = styled.button`
+  position: relative;
+  border: none;
+  background: none;
+  color: white;
+  padding: 5px;
+  display: flex;
+  justify-content: center;
+  align-items: center;
+  border-radius: 50%;
+  cursor: pointer;
+  color: #aaaabb;
+  :hover {
+    color: white;
+  }
+
+  > span {
+    font-size: 20px;
+  }
+  margin-right: 5px;
+`;
+
+const StyledSourceBox = styled.div`
+  width: 100%;
+  color: #ffffff;
+  padding: 14px 25px 30px;
+  position: relative;
+  font-size: 13px;
+  background: ${(props) => props.theme.fg};
+  border: 1px solid #494b4f;
+  border-top: 0;
+  border-bottom-left-radius: 5px;
+  border-bottom-right-radius: 5px;
+`;

+ 447 - 0
dashboard/src/main/home/cluster-dashboard/preview-environments/create-env/EnvironmentApps.tsx

@@ -0,0 +1,447 @@
+import React, { useCallback, useEffect } from "react";
+import { useState } from "react";
+import styled from "styled-components";
+import AnimateHeight from "react-animate-height";
+import { match } from "ts-pattern";
+import yaml from "js-yaml";
+
+import { Controller, useFieldArray, useFormContext } from "react-hook-form";
+import { FormData } from "../ConfigureEnvironment";
+import Spacer from "components/porter/Spacer";
+import Modal from "components/porter/Modal";
+import Text from "components/porter/Text";
+import Button from "components/porter/Button";
+import SourceSelector, {
+  SourceType,
+} from "main/home/app-dashboard/new-app-flow/SourceSelector";
+import RepositorySelector from "main/home/app-dashboard/build-settings/RepositorySelector";
+import {
+  PorterApp,
+  porterAppValidator,
+} from "main/home/app-dashboard/types/porterApp";
+import { ImportApp } from "./ImportApp";
+import Input from "components/porter/Input";
+import ImageSelector from "components/image-selector/ImageSelector";
+import api from "shared/api";
+import { UmbrellaChart, umbrellaChartValidator } from "lib/charts";
+import {
+  PorterJson,
+  PorterYamlSchema,
+} from "main/home/app-dashboard/new-app-flow/schema";
+import { Service } from "main/home/app-dashboard/new-app-flow/serviceTypes";
+import { AppBlock } from "./AppBlock";
+
+type EnvironmentAppsProps = {
+  projectId: number;
+  clusterId: number;
+};
+
+const EnvironmentApps: React.FC<EnvironmentAppsProps> = ({
+  projectId,
+  clusterId,
+}) => {
+  const { control, setValue, register } = useFormContext<FormData>();
+  const { fields, append, remove } = useFieldArray({
+    control,
+    name: "applications",
+    rules: { minLength: 1 },
+  });
+
+  const [showAddAppModal, setShowAddAppModal] = useState<boolean>(false);
+  const [source, setSource] = useState<SourceType | undefined>(undefined);
+  const [imageTag, setImageTag] = useState("latest");
+  const [tempPorterApp, setTempPorterApp] = useState<Partial<PorterApp>>(
+    PorterApp.empty()
+  );
+
+  const getPorterYamlContents = useCallback(async (app: PorterApp) => {
+    try {
+      const res = await api.getPorterYamlContents(
+        "<token>",
+        {
+          path: app.porter_yaml_path,
+        },
+        {
+          project_id: projectId,
+          git_repo_id: app.git_repo_id,
+          branch: app.git_branch,
+          owner: app.repo_name?.split("/")[0],
+          name: app.repo_name?.split("/")[1],
+          kind: "github",
+        }
+      );
+
+      if (res.data == null || res.data == "") {
+        return undefined;
+      }
+
+      const parsedYaml = yaml.load(atob(res.data));
+      const parsedData = await PorterYamlSchema.parseAsync(parsedYaml);
+      return parsedData;
+    } catch (err) {
+      // porter.yaml used as optional value so just returning undefined for now
+      return undefined;
+    }
+  }, []);
+
+  const retrievePreDeploy = useCallback(async (name: string) => {
+    try {
+      const preDeployChartData = await api.getChart(
+        "<token>",
+        {},
+        {
+          id: projectId,
+          namespace: `porter-stack-${name}`,
+          cluster_id: clusterId,
+          name: `${name}-r`,
+          // this is always latest because we do not tie the pre-deploy chart to the umbrella chart
+          revision: 0,
+        }
+      );
+      const preDeployChart = await umbrellaChartValidator.parseAsync(
+        preDeployChartData.data
+      );
+      return preDeployChart;
+    } catch (err) {
+      // that's ok if there's an error, just means there is no pre-deploy chart
+      return undefined;
+    }
+  }, []);
+
+  const retrieveServicesAndEnv = useCallback(
+    ({
+      chart,
+      releaseChart,
+      porterJson,
+    }: {
+      chart: UmbrellaChart;
+      releaseChart?: UmbrellaChart;
+      porterJson?: PorterJson;
+    }) => {
+      const helmValues = chart.config;
+      const defaultValues = chart.chart.values;
+
+      // todo(ianedwards): get env values
+
+      const services = Service.deserialize(
+        helmValues,
+        defaultValues,
+        porterJson
+      );
+
+      if (releaseChart) {
+        const release = Service.deserializeRelease(
+          releaseChart.config,
+          porterJson
+        );
+        services.push(release);
+      }
+
+      return [services];
+    },
+    []
+  );
+
+  const retrieveAndConstructApp = useCallback(async (name: string) => {
+    try {
+      const resPorterApp = await api.getPorterApp(
+        "<token>",
+        {},
+        {
+          project_id: projectId,
+          cluster_id: clusterId,
+          name,
+        }
+      );
+      const resChartData = await api.getChart(
+        "<token>",
+        {},
+        {
+          id: projectId,
+          namespace: `porter-stack-${name}`,
+          cluster_id: clusterId,
+          revision: 0,
+          name,
+        }
+      );
+
+      const chart = await umbrellaChartValidator.parseAsync(resChartData.data);
+      const appData = await porterAppValidator.parseAsync(resPorterApp.data);
+
+      const porterJson = await getPorterYamlContents({
+        ...appData,
+        porter_yaml_path: appData.porter_yaml_path ?? "porter.yaml",
+      });
+      const releaseChart = await retrievePreDeploy(name);
+
+      const [services] = retrieveServicesAndEnv({
+        chart,
+        porterJson,
+        releaseChart,
+      });
+
+      append({
+        ...appData,
+        baseYaml: porterJson,
+        services,
+        envVariables: [],
+      });
+    } catch (err) {
+      // err
+    } finally {
+      setTempPorterApp(PorterApp.empty());
+      setShowAddAppModal(false);
+    }
+  }, []);
+
+  const constructNewApp = useCallback(() => {
+    const appData = porterAppValidator.safeParse(tempPorterApp);
+    if (!appData.success) {
+      return;
+    }
+
+    append({
+      ...appData.data,
+      services: [],
+      envVariables: [],
+    });
+    setTempPorterApp(PorterApp.empty());
+    setShowAddAppModal(false);
+  }, [tempPorterApp]);
+
+  useEffect(() => {
+    setTempPorterApp(PorterApp.empty());
+  }, [source]);
+
+  return (
+    <div>
+      <AppList>
+        {fields.map((app, idx) => (
+          <AppBlock
+            app={app}
+            remove={remove}
+            idx={idx}
+            key={`${app.name}-${idx}`}
+          />
+        ))}
+      </AppList>
+      {fields.length > 0 && <Spacer y={0.5} />}
+      {showAddAppModal && (
+        <Modal closeModal={() => setShowAddAppModal(false)} width="900px">
+          <Text size={16}>Add app to environment</Text>
+          <Spacer y={1} />
+          <Text color="helper">Select a source:</Text>
+          <Spacer y={0.5} />
+          <SourceSelector
+            selectedSourceType={source}
+            setSourceType={setSource}
+            allowExisting
+          />
+          <AnimateHeight height={source ? "auto" : 0}>
+            <Spacer y={1} />
+            {source
+              ? match(source)
+                  .with("github", () =>
+                    tempPorterApp.repo_name !== undefined ? (
+                      <>
+                        {tempPorterApp.repo_name.length === 0 ? (
+                          <RepositorySelector
+                            readOnly={false}
+                            updatePorterApp={(attrs) => setTempPorterApp(attrs)}
+                            git_repo_name={tempPorterApp.repo_name}
+                          />
+                        ) : (
+                          <>
+                            <Input
+                              disabled={true}
+                              label="GitHub repository:"
+                              width="100%"
+                              value={tempPorterApp.repo_name}
+                              setValue={() => {}}
+                              placeholder=""
+                            />{" "}
+                            <BackButton
+                              width="135px"
+                              onClick={() => {
+                                setTempPorterApp((prev) => ({
+                                  repo_name: "",
+                                  ...prev,
+                                }));
+                              }}
+                            >
+                              <i className="material-icons">
+                                keyboard_backspace
+                              </i>
+                              Select repo
+                            </BackButton>
+                            <Spacer y={0.5} />
+                          </>
+                        )}
+                      </>
+                    ) : null
+                  )
+                  .with("docker-registry", () => (
+                    <>
+                      <Subtitle>
+                        Specify the container image you would like to connect to
+                        this template.
+                      </Subtitle>
+                      <ImageSelector
+                        selectedTag={imageTag}
+                        selectedImageUrl={tempPorterApp.image_repo_uri ?? ""}
+                        setSelectedImageUrl={(url) => {
+                          setTempPorterApp((prev) => ({
+                            ...prev,
+                            image_repo_uri: url,
+                          }));
+                        }}
+                        setSelectedTag={setImageTag}
+                        forceExpanded={true}
+                        listHeight="221px"
+                      />
+                    </>
+                  ))
+                  .with("existing", () => (
+                    <ImportApp
+                      projectId={projectId}
+                      clusterId={clusterId}
+                      tempApp={tempPorterApp}
+                      setTempApp={setTempPorterApp}
+                    />
+                  ))
+                  .exhaustive()
+              : null}
+          </AnimateHeight>
+          {source !== "existing" &&
+            Boolean(
+              tempPorterApp.repo_name?.length ||
+                tempPorterApp.image_repo_uri?.length
+            ) && (
+              <>
+                <Spacer y={1} />
+                <Text color="helper">Name this app:</Text>
+                <Spacer y={0.5} />
+                <Input
+                  disabled={
+                    !tempPorterApp.name?.length &&
+                    !Boolean(
+                      tempPorterApp.repo_name?.length ||
+                        tempPorterApp.image_repo_uri?.length
+                    )
+                  }
+                  placeholder="ex: my-service"
+                  width="100%"
+                  value={tempPorterApp.name ?? ""}
+                  setValue={(value: string) => {
+                    setTempPorterApp((prev) => ({
+                      ...prev,
+                      name: value,
+                    }));
+                  }}
+                />
+              </>
+            )}
+          <Spacer y={1} />
+          <Button
+            disabled={
+              !tempPorterApp.name?.length ||
+              (source === "github" && !tempPorterApp.repo_name?.length)
+            }
+            onClick={() => {
+              if (tempPorterApp.name?.length) {
+                if (source === "existing") {
+                  retrieveAndConstructApp(tempPorterApp.name);
+                } else {
+                  constructNewApp();
+                }
+              }
+            }}
+          >
+            Add app
+          </Button>
+        </Modal>
+      )}
+
+      <AddAppButton
+        onClick={() => {
+          setShowAddAppModal(true);
+        }}
+      >
+        <i className="material-icons add-icon">add_icon</i>
+        Add Application
+      </AddAppButton>
+    </div>
+  );
+};
+
+export default EnvironmentApps;
+
+const AppList = styled.div`
+  display: flex;
+  flex-direction: column;
+  width: 100%;
+  row-gap: 5px;
+`;
+
+const AddAppButton = styled.div`
+  color: #aaaabb;
+  background: ${({ theme }) => theme.fg};
+  border: 1px solid #494b4f;
+  :hover {
+    border: 1px solid #7a7b80;
+    color: white;
+  }
+  display: flex;
+  align-items: center;
+  border-radius: 5px;
+  height: 40px;
+  font-size: 13px;
+  width: 100%;
+  padding-left: 10px;
+  cursor: pointer;
+  .add-icon {
+    width: 30px;
+    font-size: 20px;
+  }
+`;
+
+export const BackButton = styled.div`
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  margin-top: 22px;
+  cursor: pointer;
+  font-size: 13px;
+  height: 35px;
+  padding: 5px 13px;
+  margin-bottom: -7px;
+  padding-right: 15px;
+  border: 1px solid #ffffff55;
+  border-radius: 100px;
+  width: ${(props: { width: string }) => props.width};
+  color: white;
+  background: #ffffff11;
+
+  :hover {
+    background: #ffffff22;
+  }
+
+  > i {
+    color: white;
+    font-size: 16px;
+    margin-right: 6px;
+  }
+`;
+
+const DarkMatter = styled.div<{ antiHeight?: string }>`
+  width: 100%;
+  margin-top: ${(props) => props.antiHeight || "-15px"};
+`;
+
+const Subtitle = styled.div`
+  padding: 11px 0px 16px;
+  font-family: "Work Sans", sans-serif;
+  font-size: 13px;
+  color: #aaaabb;
+  line-height: 1.6em;
+`;

+ 175 - 0
dashboard/src/main/home/cluster-dashboard/preview-environments/create-env/ImportApp.tsx

@@ -0,0 +1,175 @@
+import React, { Dispatch, SetStateAction } from "react";
+import { useQuery } from "@tanstack/react-query";
+import { PorterApp } from "main/home/app-dashboard/types/porterApp";
+import api from "shared/api";
+import styled from "styled-components";
+import { z } from "zod";
+import Loading from "components/Loading";
+import github from "assets/github-white.png";
+import Input from "components/porter/Input";
+import { BackButton } from "./EnvironmentApps";
+import Spacer from "components/porter/Spacer";
+
+type ImportAppProps = {
+    projectId: number;
+    clusterId: number;
+    tempApp: Partial<PorterApp>;
+    setTempApp: Dispatch<SetStateAction<Partial<PorterApp>>>;
+};
+
+export const ImportApp: React.FC<ImportAppProps> = ({
+    projectId,
+    clusterId,
+    tempApp,
+    setTempApp,
+}) => {
+    const { data, status } = useQuery<Partial<PorterApp>[]>(
+        ["getPorterApps", projectId, clusterId],
+        async () => {
+            const { data } = await api.getPorterApps(
+                "<token>",
+                {},
+                {
+                    project_id: projectId,
+                    cluster_id: clusterId,
+                }
+            );
+
+            return data.reverse();
+        }
+    );
+
+    const renderApps = () => {
+        if (status === "error" || tempApp.name === undefined) {
+            return null;
+        }
+
+        if (status === "loading") {
+            return (
+                <LoadingWrapper>
+                    <Loading />
+                </LoadingWrapper>
+            );
+        }
+
+        return tempApp.name === "" ? (
+            <AppListWrapper>
+                {data.map((app, idx) => (
+                    <AppName
+                        key={idx}
+                        isSelected={app.name === tempApp.name}
+                        lastItem={idx === data.length - 1}
+                        onClick={() => {
+                            setTempApp(app);
+                        }}
+                    >
+                        {!!app.repo_name ? (
+                            <img src={github} alt={"github icon"} />
+                        ) : (
+                            <img
+                                src="https://cdn4.iconfinder.com/data/icons/logos-and-brands/512/97_Docker_logo_logos-512.png"
+                                alt={"github icon"}
+                            />
+                        )}
+                        {app.name}
+                    </AppName>
+                ))}
+            </AppListWrapper>
+        ) : (
+            <>
+                <Input
+                    disabled={true}
+                    label="Porter App:"
+                    width="100%"
+                    value={tempApp.name}
+                    setValue={() => {}}
+                    placeholder=""
+                />
+                <BackButton
+                    width="130px"
+                    onClick={() => {
+                        setTempApp((prev) => {
+                            const newApp = {
+                                ...prev,
+                                name: "",
+                            };
+
+                            return newApp;
+                        });
+                    }}
+                >
+                    <i className="material-icons">keyboard_backspace</i>
+                    Select app
+                </BackButton>
+                <Spacer y={0.5} />
+            </>
+        );
+    };
+    return <ExpandedWrapper>{renderApps()}</ExpandedWrapper>;
+};
+
+const ExpandedWrapper = styled.div`
+    margin-top: 10px;
+    width: 100%;
+    border-radius: 3px;
+    max-height: 221px;
+`;
+
+const AppListWrapper = styled.div`
+    border: 1px solid #ffffff55;
+    border-radius: 3px;
+    overflow-y: auto;
+`;
+
+const LoadingWrapper = styled.div`
+    padding: 30px 0px;
+    background: #ffffff11;
+    display: flex;
+    align-items: center;
+    font-size: 13px;
+    justify-content: center;
+    color: #ffffff44;
+`;
+
+type AppNameProps = {
+    lastItem: boolean;
+    isSelected: boolean;
+};
+
+const AppName = styled.div<AppNameProps>`
+    display: flex;
+    width: 100%;
+    font-size: 13px;
+    border-bottom: 1px solid
+        ${(props) => (props.lastItem ? "#00000000" : "#606166")};
+    color: "#ffffff";
+    user-select: none;
+    align-items: center;
+    padding: 10px 0px;
+    cursor: pointer;
+    pointer-events: auto;
+    ${(props) => {
+        if (props.isSelected) {
+            return `background: #ffffff22;`;
+        }
+
+        return `background: #ffffff11;`;
+    }}
+
+    :hover {
+        background: #ffffff22;
+
+        > i {
+            background: #ffffff22;
+        }
+    }
+
+    > img,
+    i {
+        width: 18px;
+        height: 18px;
+        margin-left: 12px;
+        margin-right: 12px;
+        font-size: 20px;
+    }
+`;

+ 2 - 2
dashboard/src/main/home/cluster-dashboard/preview-environments/routes.tsx

@@ -1,12 +1,12 @@
 import React, { useContext } from "react";
 import { Redirect, Route, Switch, useRouteMatch } from "react-router";
 import { Context } from "shared/Context";
-import ConnectNewRepo from "./ConnectNewRepo";
 import DeploymentDetail from "./deployments/DeploymentDetail";
 import DeploymentList from "./deployments/DeploymentList";
 import EnvironmentsList from "./environments/EnvironmentsList";
 import EnvironmentSettings from "./environments/EnvironmentSettings";
 import DeployEnvironment from "./environments/CreateEnvironment";
+import { ConfigureEnvironment } from "./ConfigureEnvironment";
 
 export const Routes = () => {
   const { path } = useRouteMatch();
@@ -20,7 +20,7 @@ export const Routes = () => {
     <>
       <Switch>
         <Route path={`${path}/connect-repo`}>
-          <ConnectNewRepo />
+          <ConfigureEnvironment />
         </Route>
         <Route path={`${path}/details/:id`}>
           <DeploymentDetail />

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