diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml index daa7336c73b..e766b090a8c 100644 --- a/frontend/pnpm-lock.yaml +++ b/frontend/pnpm-lock.yaml @@ -2048,6 +2048,9 @@ importers: js-cookie: specifier: ^3.0.5 version: 3.0.5 + js-interpreter: + specifier: ^5.1.2 + version: 5.1.2 js-yaml: specifier: ^4.1.0 version: 4.1.0 @@ -16214,6 +16217,13 @@ packages: engines: {node: '>=14'} dev: false + /js-interpreter@5.1.2: + resolution: {integrity: sha512-UaGCF7yuCvMBfeVSnSdpXu8lTLW48HIr5b46qSMwqFBDO1tgb/Yxlx3LRuEDpN2DlLnFTcvfoBseUxVTjFA6zg==} + hasBin: true + dependencies: + minimist: 1.2.8 + dev: false + /js-sdsl@4.4.2: resolution: {integrity: sha512-dwXFwByc/ajSV6m5bcKAPwe4yDDF6D614pxmIi5odytzxRlwqF6nwoiCek80Ixc7Cvma5awClxrzFtxCQvcM8w==} diff --git a/frontend/providers/template/deploy/Kubefile b/frontend/providers/template/deploy/Kubefile index b1bcac1442c..dc665f3565a 100644 --- a/frontend/providers/template/deploy/Kubefile +++ b/frontend/providers/template/deploy/Kubefile @@ -9,6 +9,7 @@ ENV cloudDomain="127.0.0.1.nip.io" ENV cloudPort="" ENV certSecretName="wildcard-cert" ENV templateRepoUrl="https://github.com/labring-actions/templates" +ENV templateRepoBranch="main" ENV templateRepoPath="templates" CMD ["kubectl apply -f manifests"] \ No newline at end of file diff --git a/frontend/providers/template/deploy/manifests/deploy.yaml.tmpl b/frontend/providers/template/deploy/manifests/deploy.yaml.tmpl index 85b661b8240..e47a6eb414a 100644 --- a/frontend/providers/template/deploy/manifests/deploy.yaml.tmpl +++ b/frontend/providers/template/deploy/manifests/deploy.yaml.tmpl @@ -75,7 +75,7 @@ spec: - name: TEMPLATE_REPO_URL value: {{ .templateRepoUrl }} - name: TEMPLATE_REPO_BRANCH - value: "main" + value: {{ .templateRepoBranch }} - name: SHOW_AUTHOR value: "false" image: ghcr.io/labring/sealos-template-frontend:latest diff --git a/frontend/providers/template/package.json b/frontend/providers/template/package.json index c39bcab52c3..69597cc556b 100644 --- a/frontend/providers/template/package.json +++ b/frontend/providers/template/package.json @@ -38,6 +38,7 @@ "i18next": "^23.11.5", "immer": "^9.0.21", "js-cookie": "^3.0.5", + "js-interpreter": "^5.1.2", "js-yaml": "^4.1.0", "jszip": "^3.10.1", "lodash": "^4.17.21", diff --git a/frontend/providers/template/src/components/Select/index.tsx b/frontend/providers/template/src/components/Select/index.tsx index c3a7b4f5703..6f067bb066e 100644 --- a/frontend/providers/template/src/components/Select/index.tsx +++ b/frontend/providers/template/src/components/Select/index.tsx @@ -1,4 +1,4 @@ -import React, { useRef, forwardRef, useMemo } from 'react'; +import React, { useRef, forwardRef, useMemo, useState } from 'react'; import { Menu, Box, @@ -14,6 +14,7 @@ import MyIcon, { type IconType } from '../Icon'; interface Props extends ButtonProps { value?: string; + defaultValue?: string; placeholder?: string; list: { icon?: string; @@ -24,7 +25,7 @@ interface Props extends ButtonProps { } const MySelect = ( - { placeholder, value, width = 'auto', list, onchange, ...props }: Props, + { placeholder, value, defaultValue, width = 'auto', list, onchange, ...props }: Props, selectRef: any ) => { const ref = useRef(null); @@ -40,6 +41,8 @@ const MySelect = ( }; const { isOpen, onOpen, onClose } = useDisclosure(); + const [selectedValue, setSelectedValue] = useState(defaultValue || value || ''); + useOutsideClick({ ref: SelectRef, handler: () => { @@ -47,7 +50,10 @@ const MySelect = ( } }); - const activeMenu = useMemo(() => list.find((item) => item.value === value), [list, value]); + const activeMenu = useMemo( + () => list.find((item) => item.value === (value !== undefined ? value : selectedValue)), + [list, value, selectedValue] + ); return ( @@ -114,13 +120,14 @@ const MySelect = ( { - if (onchange && value !== item.value) { + setSelectedValue(item.value); + if (onchange) { onchange(item.value); } }} diff --git a/frontend/providers/template/src/pages/api/getTemplateSource.ts b/frontend/providers/template/src/pages/api/getTemplateSource.ts index 4272cd477d2..5686867bf95 100644 --- a/frontend/providers/template/src/pages/api/getTemplateSource.ts +++ b/frontend/providers/template/src/pages/api/getTemplateSource.ts @@ -3,13 +3,17 @@ import { getK8s } from '@/services/backend/kubernetes'; import { jsonRes } from '@/services/backend/response'; import { ApiResp } from '@/services/kubernet'; import { TemplateType } from '@/types/app'; -import { getTemplateDataSource, handleTemplateToInstanceYaml } from '@/utils/json-yaml'; +import { + getTemplateDataSource, + handleTemplateToInstanceYaml, + getYamlTemplate, +} from '@/utils/json-yaml'; import fs from 'fs'; -import yaml from 'js-yaml'; +import JsYaml from 'js-yaml'; import type { NextApiRequest, NextApiResponse } from 'next'; import path from 'path'; import { replaceRawWithCDN } from './listTemplate'; -import { EnvResponse } from '@/types'; +import { getTemplateEnvs } from '@/utils/tools'; export default async function handler(req: NextApiRequest, res: NextApiResponse) { try { @@ -26,7 +30,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse< console.log(error, 'Unauthorized allowed'); } - const { code, message, dataSource, templateYaml, TemplateEnvs, yamlList } = + const { code, message, dataSource, templateYaml, TemplateEnvs, appYaml } = await GetTemplateByName({ namespace: user_namespace, templateName: templateName @@ -43,8 +47,8 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse< ...dataSource, ...TemplateEnvs }, - yamlList: yamlList, - templateYaml: templateYaml + appYaml, + templateYaml } }); } catch (err: any) { @@ -66,15 +70,7 @@ export async function GetTemplateByName({ const cdnUrl = process.env.CDN_URL; const targetFolder = process.env.TEMPLATE_REPO_FOLDER || 'template'; - const TemplateEnvs: EnvResponse = { - SEALOS_CLOUD_DOMAIN: process.env.SEALOS_CLOUD_DOMAIN || 'cloud.sealos.io', - SEALOS_CERT_SECRET_NAME: process.env.SEALOS_CERT_SECRET_NAME || 'wildcard-cert', - TEMPLATE_REPO_URL: - process.env.TEMPLATE_REPO_URL || 'https://github.com/labring-actions/templates', - SEALOS_NAMESPACE: namespace || '', - SEALOS_SERVICE_ACCOUNT: namespace.replace('ns-', ''), - SHOW_AUTHOR: process.env.SHOW_AUTHOR || 'false' - }; + const TemplateEnvs = getTemplateEnvs(namespace) const originalPath = process.cwd(); const targetPath = path.resolve(originalPath, 'templates', targetFolder); @@ -87,10 +83,7 @@ export async function GetTemplateByName({ ? fs.readFileSync(_tempalte?.spec?.filePath, 'utf-8') : fs.readFileSync(`${targetPath}/${_tempalteName}`, 'utf-8'); - const yamlData = yaml.loadAll(yamlString); - const templateYaml: TemplateType = yamlData.find( - (item: any) => item.kind === 'Template' - ) as TemplateType; + let { appYaml, templateYaml } = getYamlTemplate(yamlString, TemplateEnvs); if (!templateYaml) { return { code: 40000, @@ -103,8 +96,7 @@ export async function GetTemplateByName({ templateYaml.spec.icon = replaceRawWithCDN(templateYaml.spec.icon, cdnUrl); } - const yamlList = yamlData.filter((item: any) => item.kind !== 'Template'); - const dataSource = getTemplateDataSource(templateYaml, TemplateEnvs); + const dataSource = getTemplateDataSource(templateYaml); // Convert template to instance const instanceName = dataSource?.defaults?.['app_name']?.value; @@ -115,14 +107,14 @@ export async function GetTemplateByName({ }; } const instanceYaml = handleTemplateToInstanceYaml(templateYaml, instanceName); - yamlList.unshift(instanceYaml); + appYaml = `${JsYaml.dump(instanceYaml)}\n---\n${appYaml}` return { code: 20000, message: 'success', dataSource, TemplateEnvs, - yamlList, + appYaml, templateYaml }; } diff --git a/frontend/providers/template/src/pages/api/platform/getEnv.ts b/frontend/providers/template/src/pages/api/platform/getEnv.ts index bec40a5fd05..b56547dab55 100644 --- a/frontend/providers/template/src/pages/api/platform/getEnv.ts +++ b/frontend/providers/template/src/pages/api/platform/getEnv.ts @@ -3,6 +3,7 @@ import { getK8s } from '@/services/backend/kubernetes'; import { jsonRes } from '@/services/backend/response'; import { ApiResp } from '@/services/kubernet'; import { EnvResponse } from '@/types/index'; +import { getTemplateEnvs } from '@/utils/tools'; import type { NextApiRequest, NextApiResponse } from 'next'; export default async function handler(req: NextApiRequest, res: NextApiResponse) { @@ -18,14 +19,6 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse< } jsonRes(res, { - data: { - SEALOS_CLOUD_DOMAIN: process.env.SEALOS_CLOUD_DOMAIN || 'cloud.sealos.io', - SEALOS_CERT_SECRET_NAME: process.env.SEALOS_CERT_SECRET_NAME || 'wildcard-cert', - TEMPLATE_REPO_URL: - process.env.TEMPLATE_REPO_URL || 'https://github.com/labring-actions/templates', - SEALOS_NAMESPACE: user_namespace || '', - SEALOS_SERVICE_ACCOUNT: user_namespace.replace('ns-', ''), - SHOW_AUTHOR: process.env.SHOW_AUTHOR || 'false' - } + data: getTemplateEnvs(user_namespace) }); } diff --git a/frontend/providers/template/src/pages/api/updateRepo.ts b/frontend/providers/template/src/pages/api/updateRepo.ts index c64ca7afcc9..a19965063d1 100644 --- a/frontend/providers/template/src/pages/api/updateRepo.ts +++ b/frontend/providers/template/src/pages/api/updateRepo.ts @@ -4,11 +4,12 @@ import { ApiResp } from '@/services/kubernet'; import { TemplateType } from '@/types/app'; import { exec } from 'child_process'; import fs from 'fs'; -import JSYAML from 'js-yaml'; import type { NextApiRequest, NextApiResponse } from 'next'; import path from 'path'; import util from 'util'; import * as k8s from '@kubernetes/client-node'; +import { getYamlTemplate } from '@/utils/json-yaml'; +import { getTemplateEnvs } from '@/utils/tools'; const execAsync = util.promisify(exec); const readFileList = (targetPath: string, fileList: unknown[] = []) => { @@ -73,6 +74,8 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse< const jsonPath = path.resolve(originalPath, 'templates.json'); const branch = process.env.TEMPLATE_REPO_BRANCH || 'main'; + const TemplateEnvs = getTemplateEnvs() + try { const gitConfigResult = await execAsync( 'git config --global --add safe.directory /app/providers/template/templates', @@ -105,13 +108,13 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse< if (!item) return; const fileName = path.basename(item); const content = fs.readFileSync(item, 'utf-8'); - const yamlTemplate = JSYAML.loadAll(content)[0] as TemplateType; - if (!!yamlTemplate) { - const appTitle = yamlTemplate.spec.title.toUpperCase(); - yamlTemplate.spec['deployCount'] = templateStaticMap[appTitle]; - yamlTemplate.spec['filePath'] = item; - yamlTemplate.spec['fileName'] = fileName; - jsonObjArr.push(yamlTemplate); + const { templateYaml } = getYamlTemplate(content, TemplateEnvs) + if (!!templateYaml) { + const appTitle = templateYaml.spec.title.toUpperCase(); + templateYaml.spec['deployCount'] = templateStaticMap[appTitle]; + templateYaml.spec['filePath'] = item; + templateYaml.spec['fileName'] = fileName; + jsonObjArr.push(templateYaml); } } catch (error) { console.log(error, 'yaml parse error'); diff --git a/frontend/providers/template/src/pages/api/v1alpha/createInstance.ts b/frontend/providers/template/src/pages/api/v1alpha/createInstance.ts index 8ac50194366..867733115f6 100644 --- a/frontend/providers/template/src/pages/api/v1alpha/createInstance.ts +++ b/frontend/providers/template/src/pages/api/v1alpha/createInstance.ts @@ -3,7 +3,6 @@ import { getK8s } from '@/services/backend/kubernetes'; import { jsonRes } from '@/services/backend/response'; import { ApiResp } from '@/services/kubernet'; import { generateYamlList, parseTemplateString } from '@/utils/json-yaml'; -import JSYAML from 'js-yaml'; import { mapValues, reduce } from 'lodash'; import type { NextApiRequest, NextApiResponse } from 'next'; import { GetTemplateByName } from '../getTemplateSource'; @@ -19,7 +18,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse< kubeconfig: await authSession(req.headers) }); - const { code, message, dataSource, templateYaml, TemplateEnvs, yamlList } = + const { code, message, dataSource, templateYaml, TemplateEnvs, appYaml } = await GetTemplateByName({ namespace, templateName @@ -40,9 +39,8 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse< }, {} ); - const yamlString = yamlList?.map((item) => JSYAML.dump(item)).join('---\n'); - const generateStr = parseTemplateString(yamlString!, /\$\{\{\s*(.*?)\s*\}\}/g, { + const generateStr = parseTemplateString(appYaml || '', { ...TemplateEnvs, defaults: _defaults, inputs: { ..._inputs, ...templateForm } diff --git a/frontend/providers/template/src/pages/api/v1alpha/exportTemplate.ts b/frontend/providers/template/src/pages/api/v1alpha/exportTemplate.ts index b7b36f8120b..ecbb743ccfe 100644 --- a/frontend/providers/template/src/pages/api/v1alpha/exportTemplate.ts +++ b/frontend/providers/template/src/pages/api/v1alpha/exportTemplate.ts @@ -6,7 +6,7 @@ import { authSession } from '@/services/backend/auth'; import { getK8s } from '@/services/backend/kubernetes'; import { parseTemplateString, generateYamlList } from '@/utils/json-yaml'; import { mapValues, reduce } from 'lodash'; -import JSYAML from 'js-yaml'; +import JsYaml from 'js-yaml'; export default async function handler(req: NextApiRequest, res: NextApiResponse) { try { @@ -19,7 +19,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse< kubeconfig: await authSession(req.headers) }); - const { code, message, dataSource, templateYaml, TemplateEnvs, yamlList } = + const { code, message, dataSource, templateYaml, TemplateEnvs, appYaml } = await GetTemplateByName({ namespace, templateName @@ -40,15 +40,14 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse< }, {} ); - const yamlString = yamlList?.map((item) => JSYAML.dump(item)).join('---\n'); - const generateStr = parseTemplateString(yamlString!, /\$\{\{\s*(.*?)\s*\}\}/g, { + const generateStr = parseTemplateString(appYaml || '', { ...TemplateEnvs, defaults: _defaults, inputs: { ..._inputs, ...templateForm } }); const correctYaml = generateYamlList(generateStr, app_name); - const yaml = JSYAML.loadAll(correctYaml[0].value); + const yaml = JsYaml.loadAll(correctYaml[0].value); jsonRes(res, { code: 200, diff --git a/frontend/providers/template/src/pages/api/v1alpha/getTemplate.ts b/frontend/providers/template/src/pages/api/v1alpha/getTemplate.ts index e4e7cb89c38..301895f3eef 100644 --- a/frontend/providers/template/src/pages/api/v1alpha/getTemplate.ts +++ b/frontend/providers/template/src/pages/api/v1alpha/getTemplate.ts @@ -13,7 +13,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse< kubeconfig: await authSession(req.headers) }); - const { code, message, dataSource, templateYaml, TemplateEnvs, yamlList } = + const { code, message, dataSource, templateYaml, TemplateEnvs, appYaml } = await GetTemplateByName({ namespace, templateName @@ -30,8 +30,8 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse< ...dataSource, ...TemplateEnvs }, - yamlList: yamlList, - templateYaml: templateYaml + appYaml, + templateYaml } }); } catch (err: any) { diff --git a/frontend/providers/template/src/pages/deploy/components/Form.tsx b/frontend/providers/template/src/pages/deploy/components/Form.tsx index aa9cdd11bc5..4642b5139d3 100644 --- a/frontend/providers/template/src/pages/deploy/components/Form.tsx +++ b/frontend/providers/template/src/pages/deploy/components/Form.tsx @@ -1,34 +1,80 @@ import MyIcon from '@/components/Icon'; import MySelect from '@/components/Select'; -import type { QueryType } from '@/types'; -import { FormSourceInput } from '@/types/app'; -import { Box, Flex, FormControl, Input, Text, useTheme } from '@chakra-ui/react'; +import type { QueryType, EnvResponse } from '@/types'; +import { FormSourceInput, TemplateSourceType } from '@/types/app'; +import { + Box, + Flex, + FormControl, + Input, + Text, + Checkbox, + NumberInput, + NumberInputField, + useTheme +} from '@chakra-ui/react'; import { useTranslation } from 'next-i18next'; import { useRouter } from 'next/router'; -import { useMemo } from 'react'; +import { useMemo, useCallback, useState, memo } from 'react'; import { UseFormReturn } from 'react-hook-form'; +import { evaluateExpression } from '@/utils/json-yaml'; +import { getTemplateValues } from '@/utils/template'; +import debounce from 'lodash/debounce'; const Form = ({ formHook, pxVal, - formSource + formSource, + platformEnvs, }: { formHook: UseFormReturn; pxVal: number; - formSource: any; + formSource: TemplateSourceType; + platformEnvs: EnvResponse; }) => { if (!formHook) return null; const { t } = useTranslation(); - const router = useRouter(); - const { templateName } = router.query as QueryType; const theme = useTheme(); - const isShowContent = useMemo(() => !!formSource?.inputs?.length, [formSource?.inputs?.length]); + const [_, setForceUpdate] = useState(false); + + const isShowContent = useMemo(() => !!formSource?.source?.inputs?.length, [formSource?.source?.inputs?.length]); const { register, - formState: { errors } + formState: { errors }, + getValues, + setValue } = formHook; + const { defaults, defaultInputs } = useMemo(() => getTemplateValues(formSource), [formSource]); + + const hasDynamicInputs = useMemo(() => { + return formSource?.source?.inputs?.some(item => item.if !== undefined); + }, [formSource?.source?.inputs]); + + const debouncedReset = useCallback( + debounce(() => { + setForceUpdate(prev => !prev); + }, 150), + [] + ); + + const evalData = { + ...platformEnvs, + ...formSource?.source, + inputs: { + ...defaultInputs, + ...getValues() + }, + defaults: defaults + } + const filteredInputs = formSource?.source?.inputs?.filter( + (item) => + item.if === undefined || + item.if?.length === 0 || + !!evaluateExpression(item.if, evalData) + ); + const boxStyles = { border: theme.borders.base, borderRadius: 'sm', @@ -54,31 +100,21 @@ const Form = ({ {isShowContent ? ( - {formSource?.inputs?.map((item: FormSourceInput, index: number) => { - if (item.type === 'choice' && item.options) { - return ( - - - - {item?.label} - {item?.required && ( - - * - - )} - - + {filteredInputs.map((item: FormSourceInput, index: number) => { + const renderInput = () => { + switch (item.type) { + case 'choice': + if (!item.options) { + console.error(`${item.key} options is required`); + return null; + } + return ( + { return { value: option, @@ -86,14 +122,51 @@ const Form = ({ }; })} onchange={(val: any) => { - formHook.setValue(item.key, val); + setValue(item.key, val); + if (hasDynamicInputs) debouncedReset(); }} /> - - - ); - } + ); + case 'boolean': + return ( + { + setValue(item.key, e.target.checked ? 'true' : 'false'); + if (hasDynamicInputs) debouncedReset(); + }} + > + {item.description && ( + + {item.description} + + )} + + ); + case 'number': + case 'string': + default: + return ( + { + if (hasDynamicInputs) debouncedReset(); + } + })} + /> + ); + } + }; + return ( @@ -113,17 +186,9 @@ const Form = ({ )} - + + {renderInput()} + ); @@ -156,4 +221,4 @@ const Form = ({ ); }; -export default Form; +export default memo(Form); diff --git a/frontend/providers/template/src/pages/deploy/index.tsx b/frontend/providers/template/src/pages/deploy/index.tsx index 09ac5df0076..af4e4542bd5 100644 --- a/frontend/providers/template/src/pages/deploy/index.tsx +++ b/frontend/providers/template/src/pages/deploy/index.tsx @@ -10,20 +10,22 @@ import { useSearchStore } from '@/store/search'; import type { QueryType, YamlItemType } from '@/types'; import { ApplicationType, TemplateSourceType } from '@/types/app'; import { serviceSideProps } from '@/utils/i18n'; -import { generateYamlList, parseTemplateString } from '@/utils/json-yaml'; +import { + generateYamlList, + parseTemplateString, +} from '@/utils/json-yaml'; import { compareFirstLanguages, deepSearch, useCopyData } from '@/utils/tools'; import { Box, Flex, Icon, Text } from '@chakra-ui/react'; import { useQuery } from '@tanstack/react-query'; -import JSYAML from 'js-yaml'; -import { mapValues, reduce } from 'lodash'; import debounce from 'lodash/debounce'; import { useTranslation } from 'next-i18next'; import dynamic from 'next/dynamic'; import { useRouter } from 'next/router'; -import { useEffect, useMemo, useState } from 'react'; +import { useCallback, useEffect, useMemo, useState } from 'react'; import { useForm } from 'react-hook-form'; import Form from './components/Form'; import ReadMe from './components/ReadMe'; +import { getTemplateInputDefaultValues, getTemplateValues } from '@/utils/template'; const ErrorModal = dynamic(() => import('./components/ErrorModal')); const Header = dynamic(() => import('./components/Header'), { ssr: false }); @@ -35,7 +37,6 @@ export default function EditApp({ appName }: { appName?: string }) { const { copyData } = useCopyData(); const { templateName } = router.query as QueryType; const { Loading, setIsLoading } = useLoading(); - const [forceUpdate, setForceUpdate] = useState(false); const { title, applyBtnText, applyMessage, applySuccess, applyError } = editModeMap(false); const [templateSource, setTemplateSource] = useState(); const [yamlList, setYamlList] = useState([]); @@ -69,65 +70,55 @@ export default function EditApp({ appName }: { appName?: string }) { return val; }, [screenWidth]); - const getFormDefaultValues = (templateSource: TemplateSourceType | undefined) => { - const inputs = templateSource?.source?.inputs; - return reduce( - inputs, - (acc, item) => { - // @ts-ignore - acc[item.key] = item.default; - return acc; + const generateYamlData = useCallback((templateSource: TemplateSourceType, inputs: Record): YamlItemType[] => { + if (!templateSource) return []; + const app_name = templateSource?.source?.defaults?.app_name?.value; + const { defaults, defaultInputs } = getTemplateValues(templateSource); + const data = { + ...platformEnvs, + ...templateSource?.source, + inputs: { + ...defaultInputs, + ...inputs }, - {} - ); - }; + defaults: defaults, + }; + const generateStr = parseTemplateString(templateSource.appYaml, data); + return generateYamlList(generateStr, app_name); + }, [platformEnvs]) - const formOnchangeDebounce = debounce((data: any) => { + const formOnchangeDebounce = useCallback(debounce((inputs: Record) => { try { if (!templateSource) return; - const app_name = templateSource?.source?.defaults?.app_name?.value; - - const yamlString = templateSource.yamlList - ?.map((item) => { - // if (item?.kind === 'Instance') { - // const _temp: TemplateInstanceType = cloneDeep(item); - // _temp.spec.defaults = templateSource?.source?.defaults; - // _temp.spec.inputs = isEmpty(data) ? null : data; - // console.log(_temp, templateSource?.source?.defaults, data); - // return JSYAML.dump(_temp); - // } - return JSYAML.dump(item); - }) - .join('---\n'); - const output = mapValues(templateSource?.source.defaults, (value) => value.value); - const generateStr = parseTemplateString(yamlString, /\$\{\{\s*(.*?)\s*\}\}/g, { - ...templateSource?.source, - inputs: data, - defaults: output - }); - setYamlList(generateYamlList(generateStr, app_name)); + const list = generateYamlData(templateSource, inputs) + setYamlList(list); } catch (error) { console.log(error); } - }, 200); + }, 500), [templateSource, generateYamlData]); - const getCachedValue = () => { - if (!cached) return null; + const getCachedValue = (): { + cachedKey: string, + [key: string]: any + } | undefined => { + if (!cached) return undefined; const cachedValue = JSON.parse(cached); - return cachedValue?.cachedKey === templateName ? cachedValue : null; + return cachedValue?.cachedKey === templateName ? cachedValue : undefined; }; // form const formHook = useForm({ - defaultValues: getFormDefaultValues(templateSource), + defaultValues: getTemplateInputDefaultValues(templateSource), values: getCachedValue() }); // watch form change, compute new yaml - formHook.watch((data: any) => { - data && formOnchangeDebounce(data); - setForceUpdate(!forceUpdate); - }); + useEffect(() => { + const subscription = formHook.watch((data: Record) => { + data && formOnchangeDebounce(data); + }); + return () => subscription.unsubscribe(); + }, [formHook, formOnchangeDebounce]); const submitSuccess = async () => { setIsLoading(true); @@ -174,32 +165,12 @@ export default function EditApp({ appName }: { appName?: string }) { }); }; - const handleTemplateSource = (res: TemplateSourceType) => { + const parseTemplate = (res: TemplateSourceType) => { try { setTemplateSource(res); - const app_name = res?.source?.defaults?.app_name?.value; - const _defaults = mapValues(res?.source.defaults, (value) => value.value); - const _inputs = getCachedValue() ? JSON.parse(cached) : getFormDefaultValues(res); - const yamlString = res.yamlList - ?.map((item) => { - // if (item?.kind === 'Instance') { - // const _temp: TemplateInstanceType = cloneDeep(item); - // _temp.spec.defaults = res.source.defaults; - // _temp.spec.inputs = isEmpty(_inputs) ? null : _inputs; - // console.log(_temp, res?.source.defaults, _inputs); - // return JSYAML.dump(_temp); - // } - return JSYAML.dump(item); - }) - .join('---\n'); - - const generateStr = parseTemplateString(yamlString, /\$\{\{\s*(.*?)\s*\}\}/g, { - ...res?.source, - defaults: _defaults, - inputs: _inputs - }); - // console.log(generateStr, '------'); - setYamlList(generateYamlList(generateStr, app_name)); + const inputs = getCachedValue() ? JSON.parse(cached) : getTemplateInputDefaultValues(res); + const list = generateYamlData(res, inputs); + setYamlList(list); } catch (err) { console.log(err, 'getTemplateData'); toast({ @@ -217,7 +188,7 @@ export default function EditApp({ appName }: { appName?: string }) { () => getTemplateSource(templateName), { onSuccess(data) { - handleTemplateSource(data); + parseTemplate(data); }, onError(err) { toast({ @@ -333,7 +304,7 @@ export default function EditApp({ appName }: { appName?: string }) { applyCb={() => formHook.handleSubmit(openConfirm(submitSuccess), submitError)()} /> -
+ {/* */} diff --git a/frontend/providers/template/src/pages/develop/components/Editor.tsx b/frontend/providers/template/src/pages/develop/components/Editor.tsx index ca7fd55ec7a..bdf3a580b19 100644 --- a/frontend/providers/template/src/pages/develop/components/Editor.tsx +++ b/frontend/providers/template/src/pages/develop/components/Editor.tsx @@ -1,44 +1,50 @@ import { Box, BoxProps } from '@chakra-ui/react'; import { StreamLanguage } from '@codemirror/language'; import { yaml } from '@codemirror/legacy-modes/mode/yaml'; -import { EditorState } from '@codemirror/state'; +import { EditorState, Text } from '@codemirror/state'; import { EditorView, keymap } from '@codemirror/view'; import { vscodeKeymap } from '@replit/codemirror-vscode-keymap'; +import { StateField } from '@codemirror/state'; import { basicSetup } from 'codemirror'; -import { useEffect, useRef, useState } from 'react'; +import { useEffect, useRef, memo } from 'react'; +import { debounce } from 'lodash'; -export default function Editor({ +function Editor({ onDocChange, ...styles -}: { onDocChange: (x: EditorState) => void } & BoxProps) { - const [init, setInit] = useState(true); - const extensions = [ - basicSetup, - keymap.of(vscodeKeymap), - StreamLanguage.define(yaml), - EditorView.updateListener.of((update) => { - // persist - const store = update.state.toJSON(); - localStorage.setItem('yamlEditor', JSON.stringify(store)); - if (update.docChanged || init) onDocChange(update.state); - init && setInit(false); - }) - ]; - const getState = () => { - try { - return EditorState.create({ - ...EditorState.fromJSON(JSON.parse(localStorage.getItem('yamlEditor')!)), - extensions - }); - } catch (err) { - return undefined; - } - }; +}: { onDocChange: (x: string) => void } & BoxProps) { const ref = useRef(null); useEffect(() => { + const storage = localStorage.getItem('developEditor') + + const debouncedOnDocChange = debounce((doc: Text) => { + const docStr = doc.toString() + localStorage.setItem('developEditor', docStr); + onDocChange(docStr); + }, 300); + + const extensions = [ + basicSetup, + keymap.of(vscodeKeymap), + StreamLanguage.define(yaml), + StateField.define({ + create: (state) => { + onDocChange(state.doc.toString()) + }, + update: (_, transaction) => { + if (transaction.docChanged) { + debouncedOnDocChange(transaction.newDoc) + } + }, + }) + ]; + const view = new EditorView({ - state: getState(), - extensions, // indentOnInput(), + state: EditorState.create({ + doc: storage || '', + extensions + }), + extensions, parent: ref.current! }); return () => view && view.destroy(); @@ -46,3 +52,5 @@ export default function Editor({ return ; } + +export default memo(Editor); \ No newline at end of file diff --git a/frontend/providers/template/src/pages/develop/components/Form.tsx b/frontend/providers/template/src/pages/develop/components/Form.tsx index b49e0875b5f..c778fe64a31 100644 --- a/frontend/providers/template/src/pages/develop/components/Form.tsx +++ b/frontend/providers/template/src/pages/develop/components/Form.tsx @@ -1,20 +1,36 @@ import MyIcon from '@/components/Icon'; import MySelect from '@/components/Select'; +import type { EnvResponse } from '@/types'; import { FormSourceInput, TemplateSourceType } from '@/types/app'; -import { Box, Flex, FormControl, Input, Text } from '@chakra-ui/react'; +import { + Box, + Flex, + FormControl, + Input, + Text, + Checkbox, + NumberInput, + NumberInputField +} from '@chakra-ui/react'; import { useTranslation } from 'next-i18next'; -import { useMemo } from 'react'; +import { useMemo, useCallback, useState, memo } from 'react'; import { UseFormReturn } from 'react-hook-form'; +import { evaluateExpression } from '@/utils/json-yaml'; +import { getTemplateValues } from '@/utils/template'; +import debounce from 'lodash/debounce'; const Form = ({ formSource, - formHook + formHook, + platformEnvs }: { formSource: TemplateSourceType; formHook: UseFormReturn; + platformEnvs: EnvResponse; }) => { if (!formHook) return null; const { t } = useTranslation(); + const [_, setForceUpdate] = useState(false); const isShowContent = useMemo( () => !!formSource?.source?.inputs?.length, @@ -23,38 +39,59 @@ const Form = ({ const { register, - formState: { errors } + formState: { errors }, + setValue, + getValues, } = formHook; + const { defaults, defaultInputs } = useMemo(() => getTemplateValues(formSource), [formSource]); + + const hasDynamicInputs = useMemo(() => { + return formSource?.source?.inputs?.some(item => item.if !== undefined); + }, [formSource?.source?.inputs]); + + const debouncedReset = useCallback( + debounce(() => { + setForceUpdate(prev => !prev) + }, 150), + [] + ); + + const evalData = { + ...platformEnvs, + ...formSource?.source, + inputs: { + ...defaultInputs, + ...getValues() + }, + defaults: defaults + } + const filteredInputs = formSource?.source?.inputs?.filter( + (item) => + item.if === undefined || + item.if?.length === 0 || + !!evaluateExpression(item.if, evalData) + ) + return ( {isShowContent ? ( - {formSource?.source?.inputs?.map((item: FormSourceInput, index: number) => { - if (item.type === 'choice' && item.options) { - return ( - - - - {item?.label} - {item?.required && ( - - * - - )} - - + {filteredInputs.map((item: FormSourceInput, index: number) => { + const renderInput = () => { + switch (item.type) { + case 'choice': + if (!item.options) { + console.error(`${item.key} options is required`); + return null; + } + return ( + { return { value: option, @@ -62,14 +99,50 @@ const Form = ({ }; })} onchange={(val: any) => { - formHook.setValue(item.key, val); + setValue(item.key, val); + if (hasDynamicInputs) debouncedReset() }} /> - - - ); - } + ); + case 'boolean': + return ( + { + setValue(item.key, e.target.checked ? 'true' : 'false'); + if (hasDynamicInputs) debouncedReset() + }} + > + {item.description && ( + + {item.description} + + )} + + ); + case 'number': + case 'string': + default: + return ( + { + if (hasDynamicInputs) debouncedReset() + } + })} + /> + ); + } + }; + return ( @@ -87,16 +160,7 @@ const Form = ({ )} - + {renderInput()} ); @@ -129,4 +193,4 @@ const Form = ({ ); }; -export default Form; +export default memo(Form); \ No newline at end of file diff --git a/frontend/providers/template/src/pages/develop/components/YamlList.tsx b/frontend/providers/template/src/pages/develop/components/YamlList.tsx index 15c45c8d75c..deb532e883e 100644 --- a/frontend/providers/template/src/pages/develop/components/YamlList.tsx +++ b/frontend/providers/template/src/pages/develop/components/YamlList.tsx @@ -1,7 +1,7 @@ import YamlCode from '@/components/YamlCode'; import type { YamlItemType } from '@/types'; import { Box, Flex, Grid } from '@chakra-ui/react'; -import { useState } from 'react'; +import { useState, memo } from 'react'; const YamlList = ({ yamlList = [] }: { yamlList: YamlItemType[] }) => { const [selectedIndex, setSelectedIndex] = useState(0); @@ -31,15 +31,15 @@ const YamlList = ({ yamlList = [] }: { yamlList: YamlItemType[] }) => { }} {...(index === selectedIndex ? { - fontWeight: 'bold', - borderColor: 'myGray.900', - backgroundColor: 'myWhite.600 !important' - } + fontWeight: 'bold', + borderColor: 'myGray.900', + backgroundColor: 'myWhite.600 !important' + } : { - color: 'myGray.500', - borderColor: 'myGray.200', - backgroundColor: 'transparent' - })} + color: 'myGray.500', + borderColor: 'myGray.200', + backgroundColor: 'transparent' + })} onClick={() => setSelectedIndex(index)} > {file.filename.replace(/-.*/, '')} @@ -55,4 +55,4 @@ const YamlList = ({ yamlList = [] }: { yamlList: YamlItemType[] }) => { ); }; -export default YamlList; +export default memo(YamlList); diff --git a/frontend/providers/template/src/pages/develop/index.tsx b/frontend/providers/template/src/pages/develop/index.tsx index 664771ca97e..6edfa49807f 100644 --- a/frontend/providers/template/src/pages/develop/index.tsx +++ b/frontend/providers/template/src/pages/develop/index.tsx @@ -10,16 +10,15 @@ import { EnvResponse } from '@/types/index'; import { serviceSideProps } from '@/utils/i18n'; import { developGenerateYamlList, - getTemplateDataSource, handleTemplateToInstanceYaml, - parseTemplateString + parseTemplateString, + getYamlSource, } from '@/utils/json-yaml'; -import { getTemplateDefaultValues } from '@/utils/template'; +import { getTemplateInputDefaultValues, getTemplateValues } from '@/utils/template'; import { downLoadBold } from '@/utils/tools'; import { Button, Center, Flex, Spinner, Text } from '@chakra-ui/react'; import { useQuery } from '@tanstack/react-query'; import dayjs from 'dayjs'; -import JsYaml from 'js-yaml'; import { debounce, has, isObject, mapValues } from 'lodash'; import { useTranslation } from 'next-i18next'; import { useCallback, useEffect, useRef, useState } from 'react'; @@ -28,14 +27,12 @@ import ErrorModal from '../deploy/components/ErrorModal'; import BreadCrumbHeader from './components/BreadCrumbHeader'; import Form from './components/Form'; import YamlList from './components/YamlList'; -import { type EditorState } from '@codemirror/state'; import Editor from './components/Editor'; export default function Develop() { const { t } = useTranslation(); const { toast } = useToast(); - const [forceUpdate, setForceUpdate] = useState(false); - const [yamlSource, setYamlSource] = useState(); + const [templateSource, setTemplateSource] = useState(); const [yamlList, setYamlList] = useState([]); const { Loading, setIsLoading } = useLoading(); const [errorMessage, setErrorMessage] = useState(''); @@ -45,60 +42,36 @@ export default function Develop() { data: EnvResponse; }; - const onYamlChange = debounce((state: EditorState) => { - const value = state.doc.toString(); - parseTemplate(value); - }, 800); - - const getYamlSource = (str: string): TemplateSourceType => { - const yamlData = JsYaml.loadAll(str); - const templateYaml: TemplateType = yamlData.find( - (item: any) => item.kind === 'Template' - ) as TemplateType; - const yamlList = yamlData.filter((item: any) => item.kind !== 'Template'); - const dataSource = getTemplateDataSource(templateYaml, platformEnvs); - const _instanceName = dataSource?.defaults?.app_name?.value || ''; - const instanceYaml = handleTemplateToInstanceYaml(templateYaml, _instanceName); - yamlList.unshift(instanceYaml); - const result: TemplateSourceType = { - source: { - ...dataSource, - ...platformEnvs - }, - yamlList: yamlList, - templateYaml: templateYaml - }; - return result; - }; - - const generateCorrectYamlList = ( + const generateYamlData = useCallback(( yamlSource: TemplateSourceType, - inputsForm = {} + inputs: Record = {} ): YamlItemType[] => { - const yamlString = yamlSource?.yamlList?.map((item) => JsYaml.dump(item)).join('---\n'); - const output = mapValues(yamlSource?.source.defaults, (value) => value.value); - const generateStr = parseTemplateString(yamlString, /\$\{\{\s*(.*?)\s*\}\}/g, { + const { defaults, defaultInputs } = getTemplateValues(yamlSource); + const data = { + ...platformEnvs, ...yamlSource?.source, - inputs: inputsForm, - defaults: output - }); + inputs: { + ...defaultInputs, + ...inputs + }, + defaults: defaults + }; + const generateStr = parseTemplateString(yamlSource.appYaml, data); const _instanceName = yamlSource?.source?.defaults?.app_name?.value || ''; - return developGenerateYamlList(generateStr, _instanceName); - }; + return developGenerateYamlList(generateStr, _instanceName) + }, [platformEnvs]); - const parseTemplate = (str: string) => { + const parseTemplate = useCallback((str: string) => { if (!str || !str.trim()) { - setYamlSource(void 0); + setTemplateSource(void 0); setYamlList([]); return; } try { - const result = getYamlSource(str); - const defaultInputes = getTemplateDefaultValues(result); + const result = getYamlSource(str, platformEnvs); const formInputs = formHook.getValues(); - - setYamlSource(result); - const correctYamlList = generateCorrectYamlList(result, { ...defaultInputes, ...formInputs }); + setTemplateSource(result); + const correctYamlList = generateYamlData(result, formInputs); setYamlList(correctYamlList); } catch (error: any) { toast({ @@ -109,29 +82,35 @@ export default function Develop() { isClosable: true }); } - }; + }, [platformEnvs, generateYamlData]); + + const onYamlChange = useCallback(debounce((doc: string) => { + parseTemplate(doc); + }, 1000), [parseTemplate]); // form const formHook = useForm({ - defaultValues: getTemplateDefaultValues(yamlSource) + defaultValues: getTemplateInputDefaultValues(templateSource) }); - // watch form change, compute new yaml - formHook.watch((data: any) => { - data && formOnchangeDebounce(data); - setForceUpdate(!forceUpdate); - }); - - const formOnchangeDebounce = debounce((data: any) => { + const formOnchangeDebounce = useCallback(debounce((formInputData: Record) => { try { - if (yamlSource) { - const correctYamlList = generateCorrectYamlList(yamlSource, data); + if (templateSource) { + const correctYamlList = generateYamlData(templateSource, formInputData); setYamlList(correctYamlList); } } catch (error) { console.log(error); } - }, 1000); + }, 500), [templateSource, generateYamlData]); + + // watch form change, compute new yaml + useEffect(() => { + const subscription = formHook.watch((data: Record) => { + data && formOnchangeDebounce(data); + }); + return () => subscription.unsubscribe(); + }, [formHook, formOnchangeDebounce]); const submitSuccess = async () => { setIsLoading(true); @@ -157,7 +136,6 @@ export default function Develop() { }; const submitError = () => { - formHook.getValues(); function deepSearch(obj: any): string { if (has(obj, 'message')) { return obj.message; @@ -247,9 +225,7 @@ export default function Develop() { w="100%" position={'relative'} overflow={'auto'} - onDocChange={(s) => { - onYamlChange(s); - }} + onDocChange={onYamlChange} /> )} @@ -280,7 +256,7 @@ export default function Develop() { {t('develop.Configure Form')} - + diff --git a/frontend/providers/template/src/pages/instance/components/header.tsx b/frontend/providers/template/src/pages/instance/components/header.tsx index aa25ce7bca8..d1036f0d256 100644 --- a/frontend/providers/template/src/pages/instance/components/header.tsx +++ b/frontend/providers/template/src/pages/instance/components/header.tsx @@ -20,7 +20,7 @@ import { useDisclosure } from '@chakra-ui/react'; import { useQuery } from '@tanstack/react-query'; -import JSYAML from 'js-yaml'; +import JsYaml from 'js-yaml'; import { useTranslation } from 'next-i18next'; import { useRouter } from 'next/router'; import { useRef, useState } from 'react'; @@ -65,7 +65,7 @@ export default function Header({ instanceName }: { instanceName: string }) { if (displayName) { yamlCR.current.metadata.labels[templateDisplayNameKey] = displayName; } - const yaml = JSYAML.dump(yamlCR.current); + const yaml = JsYaml.dump(yamlCR.current); await postDeployApp([yaml], 'replace'); refetch(); toast({ diff --git a/frontend/providers/template/src/services/backend/kubernetes.ts b/frontend/providers/template/src/services/backend/kubernetes.ts index 0c04a1f4083..42517d8a84a 100644 --- a/frontend/providers/template/src/services/backend/kubernetes.ts +++ b/frontend/providers/template/src/services/backend/kubernetes.ts @@ -1,5 +1,5 @@ import * as k8s from '@kubernetes/client-node'; -import * as yaml from 'js-yaml'; +import * as JsYaml from 'js-yaml'; // Load default kc export function K8sApiDefault(): k8s.KubeConfig { @@ -214,7 +214,7 @@ export async function getK8s({ kubeconfig }: { kubeconfig: string }) { const applyYamlList = async (yamlList: string[], type: 'create' | 'replace' | 'dryrun') => { // insert namespace const formatYaml: k8s.KubernetesObject[] = yamlList - .map((item) => yaml.loadAll(item)) + .map((item) => JsYaml.loadAll(item)) .flat() .map((item: any) => { if (item.metadata) { diff --git a/frontend/providers/template/src/store/session.ts b/frontend/providers/template/src/store/session.ts index 7d29340f2f1..f2352d57bc2 100644 --- a/frontend/providers/template/src/store/session.ts +++ b/frontend/providers/template/src/store/session.ts @@ -1,4 +1,4 @@ -import * as yaml from 'js-yaml'; +import * as JsYaml from 'js-yaml'; import type { SessionV1 } from 'sealos-desktop-sdk'; import { create } from 'zustand'; import { immer } from 'zustand/middleware/immer'; @@ -33,7 +33,7 @@ const useSessionStore = create()( if (get().session?.kubeconfig === '') { return ''; } - const doc = yaml.load(get().session.kubeconfig); + const doc = JsYaml.load(get().session.kubeconfig); //@ts-ignore return doc?.users[0]?.user?.token; } diff --git a/frontend/providers/template/src/types/app.ts b/frontend/providers/template/src/types/app.ts index fa4f044db17..be03a9d829c 100644 --- a/frontend/providers/template/src/types/app.ts +++ b/frontend/providers/template/src/types/app.ts @@ -41,23 +41,25 @@ export type TemplateType = { }; export type TemplateSourceType = { - source: { - defaults: Record< - string, - { - type: string; - value: string; - } - >; - inputs: FormSourceInput[]; - SEALOS_CERT_SECRET_NAME: string; - SEALOS_CLOUD_DOMAIN: string; - SEALOS_NAMESPACE: string; - }; - yamlList: any[]; + source: SourceType; + appYaml: string; templateYaml: TemplateType; }; +export type SourceType = { + defaults: Record< + string, + { + type: string; + value: string; + } + >; + inputs: FormSourceInput[]; + SEALOS_CERT_SECRET_NAME: string; + SEALOS_CLOUD_DOMAIN: string; + SEALOS_NAMESPACE: string; +}; + export type ProcessedTemplateSourceType = { defaults: Record< string, @@ -84,8 +86,9 @@ export type FormSourceInput = { key: string; label: string; required: boolean; - type: string; // string | number | 'choice' | boolean; + type: string; // string | number | 'choice' | boolean options?: string[]; + if?: string; }; export type TemplateInstanceType = { diff --git a/frontend/providers/template/src/types/index.ts b/frontend/providers/template/src/types/index.ts index 3dabc708a48..e86abfcb1e6 100644 --- a/frontend/providers/template/src/types/index.ts +++ b/frontend/providers/template/src/types/index.ts @@ -23,6 +23,7 @@ export type EnvResponse = { SEALOS_CLOUD_DOMAIN: string; SEALOS_CERT_SECRET_NAME: string; TEMPLATE_REPO_URL: string; + TEMPLATE_REPO_BRANCH: string; SEALOS_NAMESPACE: string; SEALOS_SERVICE_ACCOUNT: string; SHOW_AUTHOR: string; diff --git a/frontend/providers/template/src/types/js-interpreter.ts b/frontend/providers/template/src/types/js-interpreter.ts new file mode 100644 index 00000000000..278eeff9413 --- /dev/null +++ b/frontend/providers/template/src/types/js-interpreter.ts @@ -0,0 +1,2 @@ +// https://github.com/NeilFraser/JS-Interpreter +declare module 'js-interpreter'; \ No newline at end of file diff --git a/frontend/providers/template/src/utils/json-yaml.ts b/frontend/providers/template/src/utils/json-yaml.ts index 0f8e0f995ee..25875471ebd 100644 --- a/frontend/providers/template/src/utils/json-yaml.ts +++ b/frontend/providers/template/src/utils/json-yaml.ts @@ -1,16 +1,20 @@ import { YamlItemType } from '@/types'; -import { ProcessedTemplateSourceType, TemplateInstanceType, TemplateType } from '@/types/app'; -import JSYAML from 'js-yaml'; +import { ProcessedTemplateSourceType, TemplateInstanceType, TemplateType, TemplateSourceType, FormSourceInput } from '@/types/app'; +import JsYaml from 'js-yaml'; import { cloneDeep, mapValues } from 'lodash'; import { customAlphabet } from 'nanoid'; import { processEnvValue } from './tools'; import { EnvResponse } from '@/types/index'; +import Interpreter from 'js-interpreter'; const nanoid = customAlphabet('abcdefghijklmnopqrstuvwxyz'); +function base64(str: string) { + return Buffer.from(str).toString('base64'); +} export const generateYamlList = (value: string, labelName: string): YamlItemType[] => { try { - let _value = JSYAML.loadAll(value).map((item: any) => - JSYAML.dump(processEnvValue(item, labelName)) + let _value = JsYaml.loadAll(value).filter((i) => i).map((item: any) => + JsYaml.dump(processEnvValue(item, labelName)) ); return [ @@ -25,45 +29,32 @@ export const generateYamlList = (value: string, labelName: string): YamlItemType } }; +export const developGenerateYamlList = (value: string, labelName: string): YamlItemType[] => { + try { + return JsYaml.loadAll(value).filter((i) => i).map((item: any) => { + return { + filename: `${item?.kind}-${item?.metadata?.name ? item.metadata.name : nanoid(6)}.yaml`, + value: JsYaml.dump(processEnvValue(item, labelName)) + }; + }); + } catch (error) { + console.log(error, 'developGenerateYamlList'); + return []; + } +}; + export const parseTemplateString = ( sourceString: string, - regex: RegExp = /\$\{\{\s*(.*?)\s*\}\}/g, dataSource: { [key: string]: string | Record; - defaults: Record; - inputs: Record; } -) => { - // support function list - const functionHandlers = [ - { - name: 'base64', - handler: (value: string) => { - const regex = /base64\((.*?)\)/; - const match = value.match(regex); - if (match) { - const token = match[1]; - const value = token.split('.').reduce((obj: any, prop: string) => obj[prop], dataSource); - return Buffer.from(value).toString('base64'); - } - return value; - } - } - ]; +): string => { + sourceString = parseYamlIfEndif(sourceString, dataSource) + const regex = /\$\{\{\s*(.*?)\s*\}\}/g try { const replacedString = sourceString.replace(regex, (match: string, key: string) => { - if (dataSource[key] && key.indexOf('.') === -1) { - return dataSource[key]; - } - if (key.indexOf('.') !== -1) { - const hasMatchingFunction = functionHandlers.find(({ name }) => key.includes(`${name}(`)); - if (hasMatchingFunction) { - return hasMatchingFunction.handler(key); - } - const value = key.split('.').reduce((obj: any, prop: string) => obj[prop], dataSource); - return value !== undefined ? value : match; - } + return evaluateExpression(key, dataSource); }); return replacedString; } catch (error) { @@ -74,7 +65,6 @@ export const parseTemplateString = ( export const getTemplateDataSource = ( template: TemplateType, - platformEnvs?: EnvResponse ): ProcessedTemplateSourceType => { try { if (!template) { @@ -84,28 +74,6 @@ export const getTemplateDataSource = ( }; } const { defaults, inputs } = template.spec; - // support function list - const functionHandlers = [ - { - name: 'random', - handler: (value: string) => { - const length = value.match(/\${{ random\((\d+)\) }}/)?.[1]; - const randomValue = nanoid(Number(length)); - return value.replace(/\${{ random\(\d+\) }}/, randomValue); - } - } - ]; - - // handle default value - const cloneDefauls = cloneDeep(defaults); - for (let [key, item] of Object.entries(cloneDefauls)) { - for (let { name, handler } of functionHandlers) { - if (item.value && item.value.includes(`\${{ ${name}(`)) { - item.value = handler(item.value); - break; - } - } - } // handle default value for inputs const handleInputs = ( @@ -117,52 +85,36 @@ export const getTemplateDataSource = ( default: string; required: boolean; options?: string[]; - } - >, - cloneDefauls: Record< - string, - { - type: string; - value: string; + if?: string; } > - ) => { + ): FormSourceInput[] => { if (!inputs || Object.keys(inputs).length === 0) { return []; } const inputsArr = Object.entries(inputs).map(([key, item]) => { - for (let { name, handler } of functionHandlers) { - if (item.default && item.default.includes(`\${{ ${name}(`)) { - item.default = handler(item.default); - break; - } - } - const output = mapValues(cloneDefauls, (value) => value.value); return { ...item, - description: parseTemplateString(item.description, /\$\{\{\s*(.*?)\s*\}\}/g, { - ...platformEnvs, - defaults: output, - inputs: {} - }), + description: item.description, type: item.type, default: item.default, required: item.required, key: key, - label: key + label: key, + options: item.options, + if: item.if }; }); return inputsArr; }; // // handle input value - const cloneInputs = cloneDeep(inputs); - const transformedInput = handleInputs(cloneInputs, cloneDefauls); + const transformedInput = handleInputs(inputs); // console.log(cloneDefauls, transformedInput); return { - defaults: cloneDefauls, + defaults, inputs: transformedInput }; } catch (error) { @@ -174,20 +126,6 @@ export const getTemplateDataSource = ( } }; -export const developGenerateYamlList = (value: string, labelName: string): YamlItemType[] => { - try { - return JSYAML.loadAll(value).map((item: any) => { - return { - filename: `${item?.kind}-${item?.metadata?.name ? item.metadata.name : nanoid(6)}.yaml`, - value: JSYAML.dump(processEnvValue(item, labelName)) - }; - }); - } catch (error) { - console.log(error); - return []; - } -}; - export const handleTemplateToInstanceYaml = ( template: TemplateType, instanceName: string @@ -210,3 +148,208 @@ export const handleTemplateToInstanceYaml = ( } }; }; + +// https://github.com/NeilFraser/JS-Interpreter +export function evaluateExpression(expression: string, data?: { + [key: string]: any; +}): any | undefined { + try { + // console.log("expression: ", expression, " data: ", data) + // const result = new Function('data', `with(data) { return ${expression}; }`)(data); + const initInterpreterFunc = (interpreter: any, ctx: any) => { + interpreter.setProperty(ctx, 'data', interpreter.nativeToPseudo(data)); + interpreter.setProperty(ctx, 'random', interpreter.createNativeFunction(nanoid)); + interpreter.setProperty(ctx, 'base64', interpreter.createNativeFunction(base64)); + } + const interpreter = new Interpreter(`with(data) { ${expression} }`, initInterpreterFunc) + interpreter.run(); + // console.log('resoult: ', interpreter.value) + return interpreter.value; + } catch (error) { + console.error("Failed to evaluate expression: ", expression, " data: ", data, error); + return undefined; + } +}; + +export function parseYamlIfEndif(yamlStr: string, data: { + [key: string]: string | Record; +}): string { + return __parseYamlIfEndif(yamlStr, (exp) => { + return !!evaluateExpression(exp, data) + }) +} + +const yamlIfEndifReg = /^\s*\$\{\{\s*?(if|elif|else|endif)\((.*?)\)\s*?\}\}\s*$/gm; + +const __parseYamlIfEndif = (yamlStr: string, evaluateExpression: (exp: string) => boolean): string => { + const stack: RegExpMatchArray[] = []; + let ifCount = 0; + + const matches = Array.from(yamlStr.matchAll(yamlIfEndifReg)); + if (matches.length === 0) { + return yamlStr; + } + + for (const match of matches) { + const type = match[1]; + if (type === 'if') { + ifCount++; + } else if (type === 'endif') { + ifCount--; + if (ifCount < 0) { + throw new Error('endif without matching if'); + } + } + + if (type === 'if' || type === 'elif' || type === 'else') { + stack.push(match); + continue; + } + + let ifMatch: RegExpMatchArray | undefined; + let elifElseMatches: RegExpMatchArray[] = []; + + while (stack.length > 0) { + const temp = stack.pop(); + if (temp && (temp[1] === 'if' || (elifElseMatches.length > 0 && temp[1] === 'else'))) { + ifMatch = temp; + break; + } else if (temp) { + elifElseMatches.unshift(temp); + } + } + + if (!ifMatch) { + throw new Error('endif without matching if'); + } + + if (stack.length !== 0) { + continue; + } + + const start = yamlStr.substring(0, ifMatch.index); + const end = yamlStr.substring(match.index! + match[0].length); + let between = ''; + + let conditionMet = false; + if (elifElseMatches.length === 0) { + const ifResult = evaluateExpression(ifMatch[2]); + if (ifResult) { + between = yamlStr.substring(ifMatch.index! + ifMatch[0].length, match.index); + conditionMet = true; + } + } else { + for (const clause of [ifMatch, ...elifElseMatches]) { + const expression = clause[2]; + if (clause[1] === 'else' || evaluateExpression(expression)) { + between = yamlStr.substring(clause.index! + clause[0].length, clause === elifElseMatches[elifElseMatches.length - 1] ? match.index : elifElseMatches[elifElseMatches.indexOf(clause) + 1].index); + conditionMet = true; + break; + } + } + } + + if (!conditionMet && elifElseMatches.length === 0) { + between = ''; + } + + return __parseYamlIfEndif(start + between + end, evaluateExpression); + } + + if (ifCount !== 0) { + throw new Error('Unmatched if statement found'); + } + + return yamlStr; +}; + +// export function clearYamlIfEndif(yamlStr: string): string { +// return __parseYamlIfEndif(yamlStr, () => { +// return false +// }) +// } + +export function getYamlSource(str: string, platformEnvs?: EnvResponse): TemplateSourceType { + let { appYaml, templateYaml } = getYamlTemplate(str, platformEnvs) + + const dataSource = getTemplateDataSource(templateYaml); + const _instanceName = dataSource?.defaults?.app_name?.value || ''; + const instanceYaml = handleTemplateToInstanceYaml(templateYaml, _instanceName); + appYaml = `${JsYaml.dump(instanceYaml)}\n---\n${appYaml}`; + + const result: TemplateSourceType = { + source: { + ...dataSource, + ...platformEnvs! + }, + appYaml, + templateYaml + }; + return result; +} + +export function getYamlTemplate(str: string, platformEnvs?: EnvResponse): { + appYaml: string; + templateYaml: TemplateType; +} { + const yamlStrList = str.split(/^\s*---\n/m); + let templateYaml: TemplateType | undefined; + const appYamlList: string[] = []; + + for (const yamlStr of yamlStrList) { + if (templateYaml) { + appYamlList.push(yamlStr); + continue; + } + try { + templateYaml = JsYaml.load(yamlStr) as TemplateType; + } catch (error) { + throw new Error('The first YAML must be a Template and cannot use conditional rendering'); + } + if (!templateYaml || templateYaml.kind !== 'Template') { + throw new Error('The first YAML type is not Template'); + } + } + + if (!templateYaml) { + throw new Error('No valid Template found in the input YAML string'); + } + + return { + appYaml: appYamlList.join('---\n'), + templateYaml: parseTemplateYaml(templateYaml, platformEnvs) + }; +} + +function parseTemplateYaml(templateYaml: TemplateType, platformEnvs?: EnvResponse): TemplateType { + const regex = /\$\{\{\s*(.*?)\s*\}\}/g + + for (let [key, item] of Object.entries(templateYaml.spec.defaults)) { + if (item.value) { + item.value = item.value.replace(regex, (match: string, key: string) => { + return evaluateExpression(key, platformEnvs); + }) || item.value; + } + } + + const defaults = mapValues(templateYaml.spec.defaults, (value) => value.value) + for (let [key, item] of Object.entries(templateYaml.spec.inputs)) { + if (item.description) { + item.description = item.description.replace(regex, (match: string, key: string) => { + return evaluateExpression(key, { + ...platformEnvs, + defaults, + }); + }) || item.description; + } + if (item.default) { + item.default = item.default.replace(regex, (match: string, key: string) => { + return evaluateExpression(key, { + ...platformEnvs, + defaults, + }); + }) || item.default; + } + } + return templateYaml +} \ No newline at end of file diff --git a/frontend/providers/template/src/utils/template.ts b/frontend/providers/template/src/utils/template.ts index 9d589a58e7d..fcaebead051 100644 --- a/frontend/providers/template/src/utils/template.ts +++ b/frontend/providers/template/src/utils/template.ts @@ -1,7 +1,7 @@ import { TemplateSourceType } from '@/types/app'; -import { reduce } from 'lodash'; +import { reduce, mapValues } from 'lodash'; -export const getTemplateDefaultValues = (templateSource: TemplateSourceType | undefined) => { +export const getTemplateInputDefaultValues = (templateSource: TemplateSourceType | undefined) => { const inputs = templateSource?.source?.inputs; return reduce( inputs, @@ -14,6 +14,17 @@ export const getTemplateDefaultValues = (templateSource: TemplateSourceType | un ); }; +export const getTemplateDefaultValues = (templateSource: TemplateSourceType | undefined) => { + return mapValues(templateSource?.source.defaults, (value) => value.value) +}; + +export const getTemplateValues = (templateSource: TemplateSourceType | undefined) => { + return { + defaults: getTemplateDefaultValues(templateSource), + defaultInputs: getTemplateInputDefaultValues(templateSource), + } +}; + export function findTopKeyWords(keywordsList: string[][], topCount: number) { const flatKeywordsList = keywordsList.filter(Boolean).flat(); diff --git a/frontend/providers/template/src/utils/tools.ts b/frontend/providers/template/src/utils/tools.ts index 3a77abe6941..d47c9c6f67a 100644 --- a/frontend/providers/template/src/utils/tools.ts +++ b/frontend/providers/template/src/utils/tools.ts @@ -3,6 +3,7 @@ import dayjs from 'dayjs'; import { useTranslation } from 'next-i18next'; import { cloneDeep, forEach, isNumber, isBoolean, isObject, has } from 'lodash'; import { templateDeployKey } from '@/constants/keys'; +import { EnvResponse } from '@/types'; /** * copy text data @@ -262,3 +263,17 @@ export function compareFirstLanguages(acceptLanguageHeader: string) { if (indexOfEn === -1 || indexOfZh < indexOfEn) return 'zh'; return 'en'; } + +export function getTemplateEnvs(namespace?: string): EnvResponse { + const TemplateEnvs: EnvResponse = { + SEALOS_CLOUD_DOMAIN: process.env.SEALOS_CLOUD_DOMAIN || 'cloud.sealos.io', + SEALOS_CERT_SECRET_NAME: process.env.SEALOS_CERT_SECRET_NAME || 'wildcard-cert', + TEMPLATE_REPO_URL: + process.env.TEMPLATE_REPO_URL || 'https://github.com/labring-actions/templates', + TEMPLATE_REPO_BRANCH: process.env.TEMPLATE_REPO_BRANCH || 'main', + SEALOS_NAMESPACE: namespace || '', + SEALOS_SERVICE_ACCOUNT: namespace?.replace('ns-', '') || '', + SHOW_AUTHOR: process.env.SHOW_AUTHOR || 'false' + }; + return TemplateEnvs; +} \ No newline at end of file