Explorar el Código

specify addons to deploy alongside preview apps (#4101)

ianedwards hace 2 años
padre
commit
4072a99de5

La diferencia del archivo ha sido suprimido porque es demasiado grande
+ 0 - 0
dashboard/src/assets/postgresql.svg


+ 65 - 0
dashboard/src/lib/addons/index.ts

@@ -0,0 +1,65 @@
+import {
+  Addon,
+  AddonType,
+} from "@porter-dev/api-contracts/src/porter/v1/addons_pb";
+import { match } from "ts-pattern";
+import { z } from "zod";
+
+import { serviceStringValidator } from "lib/porter-apps/values";
+
+import { defaultPostgresAddon, postgresConfigValidator } from "./postgres";
+
+export const clientAddonValidator = z.object({
+  expanded: z.boolean().default(true),
+  canDelete: z.boolean().default(true),
+  name: z.object({
+    readOnly: z.boolean(),
+    value: z
+      .string()
+      .min(1, { message: "Name must be at least 1 character" })
+      .max(31, { message: "Name must be 31 characters or less" })
+      .regex(/^[a-z0-9-]{1,61}$/, {
+        message: 'Lowercase letters, numbers, and "-" only.',
+      }),
+  }),
+  envGroups: z.array(serviceStringValidator).default([]),
+  config: z.discriminatedUnion("type", [postgresConfigValidator]),
+});
+export type ClientAddon = z.infer<typeof clientAddonValidator>;
+
+export function defaultClientAddon(): ClientAddon {
+  return clientAddonValidator.parse({
+    name: { readOnly: false, value: "addon" },
+    config: defaultPostgresAddon(),
+  });
+}
+
+function addonTypeEnumProto(type: ClientAddon["config"]["type"]): AddonType {
+  return match(type)
+    .with("postgres", () => AddonType.POSTGRES)
+    .exhaustive();
+}
+
+export function clientAddonToProto(addon: ClientAddon): Addon {
+  const config = match(addon.config)
+    .with({ type: "postgres" }, (data) => ({
+      value: {
+        cpuCores: data.cpuCores.value,
+        ramMegabytes: data.ramMegabytes.value,
+        storageGigabytes: data.storageGigabytes.value,
+      },
+      case: "postgres" as const,
+    }))
+    .exhaustive();
+
+  const proto = new Addon({
+    name: addon.name.value,
+    type: addonTypeEnumProto(addon.config.type),
+    envGroups: addon.envGroups.map((envGroup) => ({
+      name: envGroup.value,
+    })),
+    config,
+  });
+
+  return proto;
+}

+ 28 - 0
dashboard/src/lib/addons/postgres.ts

@@ -0,0 +1,28 @@
+import { z } from "zod";
+
+import { serviceNumberValidator } from "lib/porter-apps/values";
+
+export const postgresConfigValidator = z.object({
+  type: z.literal("postgres"),
+  cpuCores: serviceNumberValidator.default({
+    value: 0.5,
+    readOnly: false,
+  }),
+  ramMegabytes: serviceNumberValidator.default({
+    value: 512,
+    readOnly: false,
+  }),
+  storageGigabytes: serviceNumberValidator.default({
+    value: 1,
+    readOnly: false,
+  }),
+  username: z.string().default("postgres"),
+  password: z.string().default("postgres"),
+});
+export type PostgresConfig = z.infer<typeof postgresConfigValidator>;
+
+export function defaultPostgresAddon(): PostgresConfig {
+  return postgresConfigValidator.parse({
+    type: "postgres",
+  });
+}

+ 8 - 7
dashboard/src/lib/porter-apps/index.ts

@@ -102,14 +102,15 @@ export const clientAppValidator = z.object({
 });
 export type ClientPorterApp = z.infer<typeof clientAppValidator>;
 
+export const basePorterAppFormValidator = z.object({
+  app: clientAppValidator,
+  source: sourceValidator,
+  deletions: deletionValidator,
+  redeployOnSave: z.boolean().default(false),
+});
+
 // porterAppFormValidator is used to validate inputs when creating + updating an app
-export const porterAppFormValidator = z
-  .object({
-    app: clientAppValidator,
-    source: sourceValidator,
-    deletions: deletionValidator,
-    redeployOnSave: z.boolean().default(false),
-  })
+export const porterAppFormValidator = basePorterAppFormValidator
   .refine(
     ({ app }) => {
       if (app.predeploy?.[0]?.run) {

+ 37 - 0
dashboard/src/main/home/cluster-dashboard/preview-environments/v2/setup-app/Addons.tsx

@@ -0,0 +1,37 @@
+import React from "react";
+import _ from "lodash";
+import { useFormContext } from "react-hook-form";
+
+import Button from "components/porter/Button";
+import Spacer from "components/porter/Spacer";
+import Text from "components/porter/Text";
+import { type ButtonStatus } from "main/home/app-dashboard/app-view/AppDataContainer";
+import { AddonsList } from "main/home/managed-addons/AddonsList";
+import { type PorterAppFormData } from "lib/porter-apps";
+
+type Props = {
+  buttonStatus: ButtonStatus;
+};
+
+export const Addons: React.FC<Props> = ({ buttonStatus }) => {
+  const {
+    formState: { isSubmitting },
+  } = useFormContext<PorterAppFormData>();
+
+  return (
+    <>
+      <Text size={16}>Add-ons</Text>
+      <Spacer y={0.5} />
+      <AddonsList />
+      <Spacer y={0.75} />
+      <Button
+        type="submit"
+        status={buttonStatus}
+        loadingText={"Updating..."}
+        disabled={isSubmitting}
+      >
+        Update app
+      </Button>
+    </>
+  );
+};

+ 48 - 6
dashboard/src/main/home/cluster-dashboard/preview-environments/v2/setup-app/PreviewAppDataContainer.tsx

@@ -12,6 +12,7 @@ import _ from "lodash";
 import { FormProvider, useForm } from "react-hook-form";
 import { Redirect, useHistory } from "react-router";
 import { match } from "ts-pattern";
+import { z } from "zod";
 
 import Error from "components/porter/Error";
 import Spacer from "components/porter/Spacer";
@@ -19,11 +20,11 @@ import TabSelector from "components/TabSelector";
 import { useLatestRevision } from "main/home/app-dashboard/app-view/LatestRevisionContext";
 import Environment from "main/home/app-dashboard/app-view/tabs/Environment";
 import GithubActionModal from "main/home/app-dashboard/new-app-flow/GithubActionModal";
+import { clientAddonToProto, clientAddonValidator } from "lib/addons";
 import { useAppWithPreviewOverrides } from "lib/hooks/useAppWithPreviewOverrides";
 import {
+  basePorterAppFormValidator,
   clientAppToProto,
-  porterAppFormValidator,
-  type PorterAppFormData,
   type SourceOptions,
 } from "lib/porter-apps";
 
@@ -31,6 +32,7 @@ import api from "shared/api";
 import { Context } from "shared/Context";
 
 import { type ExistingTemplateWithEnv } from "../types";
+import { Addons } from "./Addons";
 import { RequiredApps } from "./RequiredApps";
 import { ServiceSettings } from "./ServiceSettings";
 
@@ -47,6 +49,17 @@ const previewEnvSettingsTabs = [
 
 type PreviewEnvSettingsTab = (typeof previewEnvSettingsTabs)[number];
 
+const appTemplateClientValidator = basePorterAppFormValidator.extend({
+  addons: z.array(clientAddonValidator).default([]),
+});
+export type AppTemplateFormData = z.infer<typeof appTemplateClientValidator>;
+
+type EncodedAddonWithEnv = {
+  base64_addon: string;
+  variables: Record<string, string>;
+  secrets: Record<string, string>;
+};
+
 export const PreviewAppDataContainer: React.FC<Props> = ({
   existingTemplate,
 }) => {
@@ -66,6 +79,7 @@ export const PreviewAppDataContainer: React.FC<Props> = ({
     variables: {},
     secrets: {},
   });
+  const [encodedAddons, setEncodedAddons] = useState<EncodedAddonWithEnv[]>([]);
 
   const {
     porterApp,
@@ -106,9 +120,9 @@ export const PreviewAppDataContainer: React.FC<Props> = ({
     appEnv,
   });
 
-  const porterAppFormMethods = useForm<PorterAppFormData>({
+  const porterAppFormMethods = useForm<AppTemplateFormData>({
     reValidateMode: "onSubmit",
-    resolver: zodResolver(porterAppFormValidator),
+    resolver: zodResolver(appTemplateClientValidator),
     defaultValues: {
       app: withPreviewOverrides,
       source: latestSource,
@@ -153,6 +167,28 @@ export const PreviewAppDataContainer: React.FC<Props> = ({
       const proto = clientAppToProto(data);
       setValidatedAppProto(proto);
 
+      const addons = data.addons.map((addon) => {
+        const variables = match(addon.config.type)
+          .with("postgres", () => ({
+            POSTGRESQL_USERNAME: addon.config.username,
+          }))
+          .otherwise(() => ({}));
+        const secrets = match(addon.config.type)
+          .with("postgres", () => ({
+            POSTGRESQL_PASSWORD: addon.config.password,
+          }))
+          .otherwise(() => ({}));
+
+        const proto = clientAddonToProto(addon);
+
+        return {
+          base64_addon: btoa(proto.toJsonString()),
+          variables,
+          secrets,
+        };
+      });
+      setEncodedAddons(addons);
+
       const { env } = data.app;
       const variables = env
         .filter((e) => !e.hidden && !e.deleted)
@@ -179,6 +215,7 @@ export const PreviewAppDataContainer: React.FC<Props> = ({
         app: proto,
         variables,
         secrets,
+        addons,
       });
       history.push(`/preview-environments`);
     } catch (err) {
@@ -197,10 +234,12 @@ export const PreviewAppDataContainer: React.FC<Props> = ({
       app,
       variables,
       secrets,
+      addons = [],
     }: {
       app: PorterApp | null;
       variables: Record<string, string>;
       secrets: Record<string, string>;
+      addons?: EncodedAddonWithEnv[];
     }) => {
       try {
         if (!app) {
@@ -214,6 +253,7 @@ export const PreviewAppDataContainer: React.FC<Props> = ({
             variables,
             secrets,
             base_deployment_target_id: deploymentTarget.id,
+            addons,
           },
           {
             project_id: projectId,
@@ -263,7 +303,7 @@ export const PreviewAppDataContainer: React.FC<Props> = ({
           { label: "Environment Variables", value: "variables" },
           ...(currentProject?.beta_features_enabled
             ? [
-                { label: "Required Apps", value: "required-apps" },
+                // { label: "Required Apps", value: "required-apps" },
                 // { label: "Add-ons", value: "addons" },
               ]
             : []),
@@ -296,7 +336,8 @@ export const PreviewAppDataContainer: React.FC<Props> = ({
           .with("required-apps", () => (
             <RequiredApps buttonStatus={buttonStatus} />
           ))
-          .otherwise(() => null)}
+          .with("addons", () => <Addons buttonStatus={buttonStatus} />)
+          .exhaustive()}
       </form>
       {showGHAModal && (
         <GithubActionModal
@@ -316,6 +357,7 @@ export const PreviewAppDataContainer: React.FC<Props> = ({
               app: validatedAppProto,
               variables,
               secrets,
+              addons: encodedAddons,
             })
           }
           deploymentError={createError}

+ 21 - 11
dashboard/src/main/home/cluster-dashboard/preview-environments/v2/setup-app/RequiredApps.tsx

@@ -139,23 +139,33 @@ export const RequiredApps: React.FC<Props> = ({ buttonStatus }) => {
 
   const remainingApps = useMemo(() => {
     return apps.filter((a) => a.source.name !== porterApp.name);
-  }, [apps, porterApp]);
+  }, [apps, porterApp, fields.length]);
 
   return (
     <div>
       <Text size={16}>Required Apps</Text>
       <Spacer y={0.5} />
       <RequiredAppList>
-        {remainingApps.map((ra, i) => (
-          <RequiredAppRow
-            idx={i}
-            key={ra.source.name}
-            app={ra}
-            selected={fields.some((f) => f.name === ra.source.name)}
-            append={append}
-            remove={remove}
-          />
-        ))}
+        {remainingApps.map((ra) => {
+          const selectedAppIdx = fields.findIndex(
+            (f) => f.name === ra.source.name
+          );
+
+          return (
+            <RequiredAppRow
+              idx={selectedAppIdx}
+              key={
+                selectedAppIdx !== -1
+                  ? fields[selectedAppIdx].id
+                  : ra.source.name
+              }
+              app={ra}
+              append={append}
+              remove={remove}
+              selected={selectedAppIdx !== -1}
+            />
+          );
+        })}
       </RequiredAppList>
       <Spacer y={0.75} />
       <Button

+ 175 - 0
dashboard/src/main/home/managed-addons/AddonListRow.tsx

@@ -0,0 +1,175 @@
+import React from "react";
+import { AnimatePresence, motion } from "framer-motion";
+import { type UseFieldArrayUpdate } from "react-hook-form";
+import styled from "styled-components";
+import { match } from "ts-pattern";
+
+import { type ClientAddon } from "lib/addons";
+
+import postgresql from "assets/postgresql.svg";
+
+import { type AppTemplateFormData } from "../cluster-dashboard/preview-environments/v2/setup-app/PreviewAppDataContainer";
+import { PostgresTabs } from "./tabs/PostgresTabs";
+
+type AddonRowProps = {
+  index: number;
+  addon: ClientAddon;
+  update: UseFieldArrayUpdate<AppTemplateFormData, "addons">;
+  remove: (index: number) => void;
+};
+
+export const AddonListRow: React.FC<AddonRowProps> = ({
+  index,
+  addon,
+  update,
+  remove,
+}) => {
+  const renderIcon = (): JSX.Element => <Icon src={postgresql} />;
+
+  return (
+    <>
+      <AddonHeader
+        showExpanded={addon.expanded}
+        onClick={() => {
+          update(index, {
+            ...addon,
+            expanded: !addon.expanded,
+          });
+        }}
+        bordersRounded={!addon.expanded}
+      >
+        <AddonTitle>
+          <ActionButton>
+            <span className="material-icons dropdown">arrow_drop_down</span>
+          </ActionButton>
+          {renderIcon()}
+          {addon.name.value.trim().length > 0 ? addon.name.value : "New Addon"}
+        </AddonTitle>
+
+        {addon.canDelete && (
+          <ActionButton
+            onClick={(e) => {
+              e.stopPropagation();
+              remove(index);
+            }}
+          >
+            <span className="material-icons">delete</span>
+          </ActionButton>
+        )}
+      </AddonHeader>
+      <AnimatePresence>
+        {addon.expanded && (
+          <StyledSourceBox
+            key={addon.name.value}
+            initial={{
+              height: 0,
+            }}
+            animate={{
+              height: "auto",
+              transition: {
+                duration: 0.3,
+              },
+            }}
+            exit={{
+              height: 0,
+              transition: {
+                duration: 0.3,
+              },
+            }}
+            showExpanded={addon.expanded}
+          >
+            <div
+              style={{
+                padding: "14px 25px 30px",
+                border: "1px solid #494b4f",
+              }}
+            >
+              {match(addon.config.type)
+                .with("postgres", () => (
+                  <PostgresTabs index={index} addon={addon} />
+                ))
+                .exhaustive()}
+            </div>
+          </StyledSourceBox>
+        )}
+      </AnimatePresence>
+    </>
+  );
+};
+
+const AddonTitle = styled.div`
+  display: flex;
+  align-items: center;
+`;
+
+const AddonHeader = 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)"};
+  }
+`;
+
+const Icon = styled.img`
+  height: 18px;
+  margin-right: 15px;
+`;
+
+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(motion.div)<{
+  showExpanded?: boolean;
+  hasFooter?: boolean;
+}>`
+  overflow: hidden;
+  color: #ffffff;
+  font-size: 13px;
+  background: ${(props) => props.theme.fg};
+  border-top: 0;
+  border-bottom-left-radius: ${(props) => (props.hasFooter ? "0" : "5px")};
+  border-bottom-right-radius: ${(props) => (props.hasFooter ? "0" : "5px")};
+`;

+ 246 - 0
dashboard/src/main/home/managed-addons/AddonsList.tsx

@@ -0,0 +1,246 @@
+import React, { useEffect, useState } from "react";
+import { zodResolver } from "@hookform/resolvers/zod";
+import {
+  Controller,
+  useFieldArray,
+  useForm,
+  useFormContext,
+} from "react-hook-form";
+import styled from "styled-components";
+import { match } from "ts-pattern";
+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 Select from "components/porter/Select";
+import Spacer from "components/porter/Spacer";
+import Text from "components/porter/Text";
+import { type AppTemplateFormData } from "main/home/cluster-dashboard/preview-environments/v2/setup-app/PreviewAppDataContainer";
+import { defaultClientAddon } from "lib/addons";
+
+import postgresql from "assets/postgresql.svg";
+
+import { AddonListRow } from "./AddonListRow";
+
+const addAddonFormValidator = z.object({
+  name: z
+    .string()
+    .min(1, { message: "A service name is required" })
+    .max(30)
+    .regex(/^[a-z0-9-]+$/, {
+      message: 'Lowercase letters, numbers, and " - " only.',
+    }),
+  type: z.enum(["postgres"]),
+});
+type AddAddonFormValues = z.infer<typeof addAddonFormValidator>;
+
+export const AddonsList: React.FC = () => {
+  const [showAddAddonModal, setShowAddAddonModal] = useState(false);
+
+  const { control: appTemplateControl } = useFormContext<AppTemplateFormData>();
+
+  // add addon modal form
+  const {
+    register,
+    watch,
+    control,
+    reset,
+    handleSubmit,
+    formState: { errors },
+    setError,
+    clearErrors,
+  } = useForm<AddAddonFormValues>({
+    reValidateMode: "onChange",
+    resolver: zodResolver(addAddonFormValidator),
+    defaultValues: {
+      name: "",
+      type: "postgres",
+    },
+  });
+
+  const addonName = watch("name");
+  const addonType = watch("type");
+
+  const { append, update, remove, fields } = useFieldArray({
+    control: appTemplateControl,
+    name: "addons",
+  });
+
+  useEffect(() => {
+    const existingAddonNames = fields.map((f) => f.name);
+    if (existingAddonNames.some((n) => n.value === addonName)) {
+      setError("name", {
+        message: "Addon name must be unique",
+      });
+    } else {
+      clearErrors("name");
+    }
+  }, [fields]);
+
+  const onSubmit = handleSubmit((data) => {
+    const baseAddon = defaultClientAddon();
+    append({
+      ...baseAddon,
+      name: {
+        value: data.name,
+        readOnly: false,
+      },
+    });
+
+    reset();
+    setShowAddAddonModal(false);
+  });
+
+  return (
+    <>
+      <AddonsContainer>
+        {fields.map((addon, idx) => (
+          <AddonListRow
+            key={addon.id}
+            index={idx}
+            addon={addon}
+            update={update}
+            remove={remove}
+          />
+        ))}
+      </AddonsContainer>
+      {fields.length === 0 && (
+        <>
+          <AddAddonButton
+            onClick={() => {
+              setShowAddAddonModal(true);
+            }}
+          >
+            <I className="material-icons add-icon">add</I>
+            Include add-on in preview environments
+          </AddAddonButton>
+          <Spacer y={0.5} />
+        </>
+      )}
+      {showAddAddonModal && (
+        <Modal
+          closeModal={() => {
+            setShowAddAddonModal(false);
+          }}
+          width="500px"
+        >
+          <Text size={16}>Include an addon in your preview environment</Text>
+          <Spacer y={1} />
+          <Text color="helper">Select a service type:</Text>
+          <Spacer y={0.5} />
+          <Container row>
+            <AddonIcon>
+              {match(addonType)
+                .with("postgres", () => <img src={postgresql} />)
+                .exhaustive()}
+            </AddonIcon>
+            <Controller
+              name="type"
+              control={control}
+              render={({ field: { onChange } }) => (
+                <Select
+                  value={addonType}
+                  width="100%"
+                  setValue={(value: string) => {
+                    onChange(value);
+                  }}
+                  options={[{ label: "Postgres", value: "postgres" }]}
+                />
+              )}
+            />
+          </Container>
+          <Spacer y={1} />
+          <Text color="helper">Name this service:</Text>
+          <Spacer y={0.5} />
+          <ControlledInput
+            type="text"
+            placeholder="ex: my-postgres"
+            width="100%"
+            error={errors.name?.message}
+            {...register("name")}
+          />
+          <Spacer y={1} />
+          <Button
+            type="button"
+            onClick={onSubmit}
+            disabled={!!errors.name?.message}
+          >
+            <I className="material-icons">add</I> Add service
+          </Button>
+        </Modal>
+      )}
+    </>
+  );
+};
+
+const AddonsContainer = styled.div`
+  animation: fadeIn 0.3s 0s;
+  @keyframes fadeIn {
+    from {
+      opacity: 0;
+    }
+    to {
+      opacity: 1;
+    }
+  }
+`;
+
+const AddonIcon = styled.div`
+  border: 1px solid #494b4f;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  height: 35px;
+  width: 35px;
+  min-width: 35px;
+  margin-right: 10px;
+  overflow: hidden;
+  border-radius: 5px;
+  > img {
+    height: 18px;
+    animation: floatIn 0.5s 0s;
+    @keyframes floatIn {
+      from {
+        opacity: 0;
+        transform: translateY(7px);
+      }
+      to {
+        opacity: 1;
+        transform: translateY(0px);
+      }
+    }
+  }
+`;
+
+const AddAddonButton = 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;
+  }
+`;
+
+const I = styled.i`
+  color: white;
+  font-size: 14px;
+  display: flex;
+  align-items: center;
+  margin-right: 7px;
+  justify-content: center;
+`;

+ 191 - 0
dashboard/src/main/home/managed-addons/tabs/PostgresTabs.tsx

@@ -0,0 +1,191 @@
+import React, { useMemo, useState } from "react";
+import { Controller, useFormContext } from "react-hook-form";
+import styled from "styled-components";
+import { match } from "ts-pattern";
+
+import CopyToClipboard from "components/CopyToClipboard";
+import { ControlledInput } from "components/porter/ControlledInput";
+import Spacer from "components/porter/Spacer";
+import Text from "components/porter/Text";
+import TabSelector from "components/TabSelector";
+import IntelligentSlider from "main/home/app-dashboard/validate-apply/services-settings/tabs/IntelligentSlider";
+import { type AppTemplateFormData } from "main/home/cluster-dashboard/preview-environments/v2/setup-app/PreviewAppDataContainer";
+import { type ClientAddon } from "lib/addons";
+
+import { useClusterResources } from "shared/ClusterResourcesContext";
+import copy from "assets/copy-left.svg";
+
+type Props = {
+  index: number;
+  addon: ClientAddon & {
+    config: {
+      type: "postgres";
+    };
+  };
+};
+
+export const PostgresTabs: React.FC<Props> = ({ index }) => {
+  const { register, control, watch } = useFormContext<AppTemplateFormData>();
+  const {
+    currentClusterResources: { maxCPU, maxRAM },
+  } = useClusterResources();
+
+  const [currentTab, setCurrentTab] = useState<"credentials" | "resources">(
+    "credentials"
+  );
+
+  const name = watch(`addons.${index}.name`);
+  const username = watch(`addons.${index}.config.username`);
+  const password = watch(`addons.${index}.config.password`);
+
+  const databaseURL = useMemo(() => {
+    if (!username || !password || !name.value) {
+      return "";
+    }
+
+    return `postgresql://${username}:${password}@${name.value}-postgres:5432/postgres`;
+  }, [username, password, name.value]);
+
+  return (
+    <>
+      <TabSelector
+        options={[
+          { label: "Credentials", value: "credentials" },
+          { label: "Resources", value: "resources" },
+        ]}
+        currentTab={currentTab}
+        setCurrentTab={setCurrentTab}
+      />
+      <Spacer y={1} />
+      {match(currentTab)
+        .with("credentials", () => (
+          <>
+            <Text color="helper">Postgres Username</Text>
+            <Spacer y={0.25} />
+            <ControlledInput
+              type="text"
+              placeholder="postgres"
+              width="300px"
+              {...register(`addons.${index}.config.username`)}
+            />
+            <Spacer y={1} />
+            <Text color="helper">Postgres Password</Text>
+            <Spacer y={0.25} />
+            <ControlledInput
+              type="text"
+              width="300px"
+              {...register(`addons.${index}.config.password`)}
+            />
+            <Spacer y={1} />
+            {databaseURL && (
+              <>
+                <Text color="helper">Internal Database URL:</Text>
+                <Spacer y={0.5} />
+                <IdContainer>
+                  <Code>{databaseURL}</Code>
+                  <CopyContainer>
+                    <CopyToClipboard text={databaseURL}>
+                      <CopyIcon src={copy} alt="copy" />
+                    </CopyToClipboard>
+                  </CopyContainer>
+                </IdContainer>
+                <Spacer y={0.5} />
+              </>
+            )}
+          </>
+        ))
+        .with("resources", () => (
+          <>
+            <Controller
+              name={`addons.${index}.config.cpuCores`}
+              control={control}
+              render={({ field: { value, onChange } }) => (
+                <IntelligentSlider
+                  label="CPUs: "
+                  unit="Cores"
+                  min={0.01}
+                  max={maxCPU}
+                  color={"#3f51b5"}
+                  value={value.value.toString()}
+                  setValue={(e) => {
+                    onChange({
+                      ...value,
+                      value: e,
+                    });
+                  }}
+                  step={0.1}
+                  disabled={value.readOnly}
+                  disabledTooltip={
+                    "You may only edit this field in your porter.yaml."
+                  }
+                  isSmartOptimizationOn={false}
+                  decimalsToRoundTo={2}
+                />
+              )}
+            />
+            <Spacer y={1} />
+            <Controller
+              name={`addons.${index}.config.ramMegabytes`}
+              control={control}
+              render={({ field: { value, onChange } }) => (
+                <IntelligentSlider
+                  label="RAM: "
+                  unit="MB"
+                  min={1}
+                  max={maxRAM}
+                  color={"#3f51b5"}
+                  value={value.value.toString()}
+                  setValue={(e) => {
+                    onChange({
+                      ...value,
+                      value: e,
+                    });
+                  }}
+                  step={10}
+                  disabled={value.readOnly}
+                  disabledTooltip={
+                    "You may only edit this field in your porter.yaml."
+                  }
+                  isSmartOptimizationOn={false}
+                />
+              )}
+            />
+          </>
+        ))
+        .exhaustive()}
+    </>
+  );
+};
+
+const CopyIcon = styled.img`
+  cursor: pointer;
+  margin-left: 5px;
+  margin-right: 5px;
+  width: 15px;
+  height: 15px;
+  :hover {
+    opacity: 0.8;
+  }
+`;
+
+const Code = styled.span`
+  font-family: monospace;
+`;
+
+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;
+`;
+
+const CopyContainer = styled.div`
+  display: flex;
+  align-items: center;
+  margin-left: auto;
+`;

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

@@ -981,6 +981,11 @@ const createAppTemplate = baseApi<
     variables: Record<string, string>;
     secrets: Record<string, string>;
     base_deployment_target_id: string;
+    addons?: Array<{
+      base64_addon: string;
+      variables: Record<string, string>;
+      secrets: Record<string, string>;
+    }>
   },
   {
     project_id: number;

Algunos archivos no se mostraron porque demasiados archivos cambiaron en este cambio