diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml index 2406f6e0b95..6e48aa7038f 100644 --- a/frontend/pnpm-lock.yaml +++ b/frontend/pnpm-lock.yaml @@ -1472,6 +1472,9 @@ importers: '@kubernetes/client-node': specifier: ^0.21.0 version: 0.21.0 + '@prisma/client': + specifier: ^5.10.2 + version: 5.10.2(prisma@5.10.2) '@sealos/ui': specifier: workspace:^ version: link:../../packages/ui @@ -1532,6 +1535,9 @@ importers: nprogress: specifier: ^0.2.0 version: 0.2.0 + prisma: + specifier: ^5.10.2 + version: 5.10.2 react: specifier: ^18 version: 18.2.0 @@ -1556,6 +1562,9 @@ importers: sealos-desktop-sdk: specifier: workspace:* version: link:../../packages/client-sdk + zod: + specifier: ^3.23.8 + version: 3.23.8 zustand: specifier: ^4.5.4 version: 4.5.4(@types/react@18.2.37)(immer@10.1.1)(react@18.2.0) @@ -11041,6 +11050,7 @@ packages: /acorn-import-assertions@1.9.0(acorn@8.11.2): resolution: {integrity: sha512-cmMwop9x+8KFhxvKrKfPYmN6/pKTYYHBqLa0DfvVZcKMJWNyWLnaqND7dx/qn66R7ewM1UX5XMaDVP5wlVTaVA==} + deprecated: package has been renamed to acorn-import-attributes peerDependencies: acorn: ^8 dependencies: @@ -14289,7 +14299,7 @@ packages: debug: 3.2.7 eslint: 8.57.0 eslint-import-resolver-node: 0.3.9 - eslint-import-resolver-typescript: 3.6.1(@typescript-eslint/parser@6.13.1)(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.31.0)(eslint@8.57.0) + eslint-import-resolver-typescript: 3.6.1(@typescript-eslint/parser@6.13.1)(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.29.0)(eslint@8.57.0) transitivePeerDependencies: - supports-color dev: true diff --git a/frontend/providers/devbox/.env.template b/frontend/providers/devbox/.env.template index c0bc5d8b6a4..3dd1c90bc8a 100644 --- a/frontend/providers/devbox/.env.template +++ b/frontend/providers/devbox/.env.template @@ -6,3 +6,6 @@ DEVBOX_AFFINITY_ENABLE= SQUASH_ENABLE= NODE_TLS_REJECT_UNAUTHORIZED= ROOT_RUNTIME_NAMESPACE= +DATABASE_URL= +RETAG_SVC_URL= +PRIVACY_URL= \ No newline at end of file diff --git a/frontend/providers/devbox/.gitignore b/frontend/providers/devbox/.gitignore index fd3dbb571a1..ab95baa5994 100644 --- a/frontend/providers/devbox/.gitignore +++ b/frontend/providers/devbox/.gitignore @@ -34,3 +34,5 @@ yarn-error.log* # typescript *.tsbuildinfo next-env.d.ts + +prisma/generated \ No newline at end of file diff --git a/frontend/providers/devbox/api/devbox.ts b/frontend/providers/devbox/api/devbox.ts index 6cec0b4fc2e..9f94747da1e 100644 --- a/frontend/providers/devbox/api/devbox.ts +++ b/frontend/providers/devbox/api/devbox.ts @@ -1,40 +1,42 @@ import { V1Deployment, V1Pod, V1StatefulSet } from '@kubernetes/client-node' +import { DELETE, GET, POST } from '@/services/request' +import { GetDevboxByNameReturn } from '@/types/adapt' import { - DevboxEditType, - DevboxListItemType, + DevboxEditTypeV2, + DevboxListItemTypeV2, DevboxPatchPropsType, - DevboxVersionListItemType, - runtimeNamespaceMapType + DevboxVersionListItemType } from '@/types/devbox' +import { KBDevboxReleaseType, KBDevboxTypeV2 } from '@/types/k8s' +import { MonitorDataResult, MonitorQueryKey } from '@/types/monitor' import { adaptAppListItem, - adaptDevboxDetail, - adaptDevboxListItem, + adaptDevboxDetailV2, + adaptDevboxListItemV2, adaptDevboxVersionListItem, adaptPod } from '@/utils/adapt' -import { GET, POST, DELETE } from '@/services/request' -import { KBDevboxType, KBDevboxReleaseType } from '@/types/k8s' -import { MonitorDataResult, MonitorQueryKey } from '@/types/monitor' export const getMyDevboxList = () => - GET('/api/getDevboxList').then((data): DevboxListItemType[] => - data.map(adaptDevboxListItem).sort((a, b) => { + GET<[KBDevboxTypeV2, { + templateRepository: { + iconId: string | null; + }; + uid: string; + }][]>('/api/getDevboxList').then((data): DevboxListItemTypeV2[] => + data.map(adaptDevboxListItemV2).sort((a, b) => { return new Date(b.createTime).getTime() - new Date(a.createTime).getTime() }) ) export const getDevboxByName = (devboxName: string) => - GET('/api/getDevboxByName', { devboxName }).then((data) => - adaptDevboxDetail(data) - ) + GET('/api/getDevboxByName', { devboxName }).then(adaptDevboxDetailV2) export const applyYamlList = (yamlList: string[], type: 'create' | 'replace' | 'update') => POST('/api/applyYamlList', { yamlList, type }) export const createDevbox = (payload: { - devboxForm: DevboxEditType - runtimeNamespaceMap: runtimeNamespaceMapType + devboxForm: DevboxEditTypeV2 }) => POST(`/api/createDevbox`, payload) export const updateDevbox = (payload: { patch: DevboxPatchPropsType; devboxName: string }) => @@ -69,8 +71,16 @@ export const editDevboxVersion = (data: { name: string; releaseDes: string }) => export const delDevboxVersionByName = (versionName: string) => DELETE('/api/delDevboxVersionByName', { versionName }) -export const getSSHConnectionInfo = (data: { devboxName: string; runtimeName: string }) => - GET('/api/getSSHConnectionInfo', data) +export const getSSHConnectionInfo = (data: { devboxName: string }) => + GET<{ + base64PublicKey: string; + base64PrivateKey: string; + token: string; + userName: string; + workingDir: string; + releaseCommand: string; + releaseArgs: string; +}>('/api/getSSHConnectionInfo', data) export const getDevboxPodsByDevboxName = (name: string) => GET('/api/getDevboxPodsByDevboxName', { name }).then((item) => item.map(adaptPod)) @@ -81,9 +91,6 @@ export const getDevboxMonitorData = (payload: { step: string }) => GET(`/api/monitor/getMonitorData`, payload) -export const getSSHRuntimeInfo = (runtimeName: string) => - GET('/api/getSSHRuntimeInfo', { runtimeName }) - export const getAppsByDevboxId = (devboxId: string) => GET('/api/getAppsByDevboxId', { devboxId }).then((res) => res.map(adaptAppListItem) diff --git a/frontend/providers/devbox/api/template.ts b/frontend/providers/devbox/api/template.ts new file mode 100644 index 00000000000..454568a3fc2 --- /dev/null +++ b/frontend/providers/devbox/api/template.ts @@ -0,0 +1,132 @@ +import { Tag, TemplateRepositoryKind } from "@/prisma/generated/client"; +import { DELETE, GET, POST } from "@/services/request"; +import { CreateTemplateRepositoryType, UpdateTemplateRepositoryType, UpdateTemplateType } from "@/utils/vaildate"; + +export const listOfficialTemplateRepository = () => GET<{ + templateRepositoryList: { + uid: string; + name: string; + kind: TemplateRepositoryKind; + iconId: string; + description: string | null; + }[] +}>(`/api/templateRepository/listOfficial`) +export const listTemplateRepository = (page: { + page: number, + pageSize: number, +}, tags?: string[], search?: string) => { + const searchParams = new URLSearchParams() + if (tags && tags.length > 0) { + tags.forEach((tag) => { + searchParams.append('tags', tag) + }) + } + searchParams.append('page', page.page.toString()) + searchParams.append('pageSize', page.pageSize.toString()) + if (search) searchParams.append('search', search) + return GET<{ + templateRepositoryList: { + uid: string; + name: string; + description: string | null; + iconId: string | null; + templates: { + uid: string; + name: string; + }[]; + templateRepositoryTags: { + tag: Tag; + }[]; + }[], + page: { + page: number, + pageSize: number, + totalItems: number, + totalPage: number, + } + }>(`/api/templateRepository/list?${searchParams.toString()}`) + +} +export const listPrivateTemplateRepository = ({ + search, + page, + pageSize, +}: { + search?: string, + page?: number, + pageSize?: number, +} = {}) => { + const searchParams = new URLSearchParams() + + if (search) searchParams.append('search', search) + if (page) searchParams.append('page', page.toString()) + if (pageSize) searchParams.append('pageSize', pageSize.toString()) + return GET<{ + templateRepositoryList: { + uid: string; + name: string; + description: string | null; + iconId: string | null; + templates: { + uid: string; + name: string; + }[]; + isPublic: boolean; + templateRepositoryTags: { + tag: Tag; + }[]; + }[], + page: { + page: number, + pageSize: number, + totalItems: number, + totalPage: number, + } + }>(`/api/templateRepository/listPrivate?${searchParams.toString()}`) +} + +export const getTemplateRepository = (uid: string) => GET<{ + templateRepository: { + templates: { + name: string; + uid: string; + }[]; + uid: string; + isPublic: true; + name: string; + description: string | null; + iconId: string | null; + templateRepositoryTags: { + tag: Tag; + }[]; + } +}>(`/api/templateRepository/get?uid=${uid}`) +export const getTemplateConfig = (uid: string) => GET<{ + template: { + name: string; + uid: string; + config: string; + } +}>(`/api/templateRepository/template/getConfig?uid=${uid}`) +export const listTemplate = (templateRepositoryUid: string) => GET<{ + templateList: { + uid: string; + name: string; + config: string; + image: string; + createAt: Date; + updateAt: Date; + }[] +}>(`/api/templateRepository/template/list?templateRepositoryUid=${templateRepositoryUid}`) +export const listTag = () => GET<{ + tagList: Tag[] +}>(`/api/templateRepository/tag/list`) + +export const createTemplateReposistory = (data: CreateTemplateRepositoryType) => POST(`/api/templateRepository/withTemplate/create`, data) +export const initUser = () => POST(`/api/auth/init`) + +export const deleteTemplateRepository = (templateRepositoryUid: string) => DELETE(`/api/templateRepository/delete?templateRepositoryUid=${templateRepositoryUid}`) + +export const updateTemplateReposistory = (data: UpdateTemplateRepositoryType) => POST(`/api/templateRepository/update`, data) +export const updateTemplate = (data: UpdateTemplateType) => POST(`/api/templateRepository/withTemplate/update`, data) +export const deleteTemplate = (templateUid: string) => DELETE(`/api/templateRepository/template/delete?uid=${templateUid}`) \ No newline at end of file diff --git a/frontend/providers/devbox/app/[lang]/(platform)/(home)/components/DevboxHeader.tsx b/frontend/providers/devbox/app/[lang]/(platform)/(home)/components/DevboxHeader.tsx new file mode 100644 index 00000000000..46ad42d6011 --- /dev/null +++ b/frontend/providers/devbox/app/[lang]/(platform)/(home)/components/DevboxHeader.tsx @@ -0,0 +1,90 @@ +import MyIcon from "@/components/Icon"; +import { TemplateState } from "@/constants/template"; +import { usePathname, useRouter } from "@/i18n"; +import { useTemplateStore } from "@/stores/template"; +import { Box, Button, Center, Flex, Text, useTheme } from "@chakra-ui/react"; +import { useTranslations } from "next-intl"; +import { useEffect } from "react"; + +export default function DevboxHeader({ listLength }: { listLength: number }) { + const { openTemplateModal, config, updateTemplateModalConfig } = useTemplateStore() + const theme = useTheme() + const router = useRouter() + const t = useTranslations() + const pathname = usePathname() + const lastRoute = '/?openTemplate=publicTemplate' + useEffect(() => { + const refreshLastRoute = '/' + if (config.lastRoute.includes('openTemplate')) { + openTemplateModal({ + ...config, + lastRoute: refreshLastRoute + }) + } else { + updateTemplateModalConfig({ + ...config, + lastRoute: refreshLastRoute + }) + } + }, []) + return +
+ +
+ + {t('devbox_list')} + + + ( {listLength} ) + + { + // setLastRoute(pathname) + openTemplateModal({ + 'templateState': TemplateState.publicTemplate, + lastRoute, + }) + }} + > + + + {t("scan_templates")} + + + +
; +} \ No newline at end of file diff --git a/frontend/providers/devbox/app/[lang]/(platform)/(home)/components/DevboxList.tsx b/frontend/providers/devbox/app/[lang]/(platform)/(home)/components/DevboxList.tsx index ff37ed8b9f8..2a588dd5358 100644 --- a/frontend/providers/devbox/app/[lang]/(platform)/(home)/components/DevboxList.tsx +++ b/frontend/providers/devbox/app/[lang]/(platform)/(home)/components/DevboxList.tsx @@ -1,20 +1,20 @@ -import dynamic from 'next/dynamic' +import { Box, Button, Flex, Image, MenuButton, Text } from '@chakra-ui/react' +import { MyTable, SealosMenu, useMessage } from '@sealos/ui' import { useTranslations } from 'next-intl' +import dynamic from 'next/dynamic' import { useCallback, useState } from 'react' import { sealosApp } from 'sealos-desktop-sdk/app' -import { SealosMenu, MyTable, useMessage } from '@sealos/ui' -import { Box, Button, Center, Flex, Image, MenuButton, useTheme, Text } from '@chakra-ui/react' +import { pauseDevbox, restartDevbox, startDevbox } from '@/api/devbox' import { useRouter } from '@/i18n' import { useGlobalStore } from '@/stores/global' -import { DevboxListItemType } from '@/types/devbox' -import { pauseDevbox, restartDevbox, startDevbox } from '@/api/devbox' +import { DevboxListItemTypeV2 } from '@/types/devbox' +import DevboxStatusTag from '@/components/DevboxStatusTag' import MyIcon from '@/components/Icon' import IDEButton from '@/components/IDEButton' -import PodLineChart from '@/components/PodLineChart' -import DevboxStatusTag from '@/components/DevboxStatusTag' import ReleaseModal from '@/components/modals/releaseModal' +import PodLineChart from '@/components/PodLineChart' const DelModal = dynamic(() => import('@/components/modals/DelModal')) @@ -22,10 +22,10 @@ const DevboxList = ({ devboxList = [], refetchDevboxList }: { - devboxList: DevboxListItemType[] + devboxList: DevboxListItemTypeV2[] refetchDevboxList: () => void }) => { - const theme = useTheme() + const router = useRouter() const t = useTranslations() const { message: toast } = useMessage() @@ -34,17 +34,17 @@ const DevboxList = ({ const { setLoading } = useGlobalStore() const [onOpenRelease, setOnOpenRelease] = useState(false) - const [delDevbox, setDelDevbox] = useState(null) - const [currentDevboxListItem, setCurrentDevboxListItem] = useState( + const [delDevbox, setDelDevbox] = useState(null) + const [currentDevboxListItem, setCurrentDevboxListItem] = useState( null ) - const handleOpenRelease = (devbox: DevboxListItemType) => { + const handleOpenRelease = (devbox: DevboxListItemTypeV2) => { setCurrentDevboxListItem(devbox) setOnOpenRelease(true) } const handlePauseDevbox = useCallback( - async (devbox: DevboxListItemType) => { + async (devbox: DevboxListItemTypeV2) => { try { setLoading(true) await pauseDevbox({ devboxName: devbox.name }) @@ -65,7 +65,7 @@ const DevboxList = ({ [refetchDevboxList, setLoading, t, toast] ) const handleRestartDevbox = useCallback( - async (devbox: DevboxListItemType) => { + async (devbox: DevboxListItemTypeV2) => { try { setLoading(true) await restartDevbox({ devboxName: devbox.name }) @@ -86,7 +86,7 @@ const DevboxList = ({ [refetchDevboxList, setLoading, t, toast] ) const handleStartDevbox = useCallback( - async (devbox: DevboxListItemType) => { + async (devbox: DevboxListItemTypeV2) => { try { setLoading(true) await startDevbox({ devboxName: devbox.name }) @@ -107,7 +107,7 @@ const DevboxList = ({ [refetchDevboxList, setLoading, t, toast] ) const handleGoToTerminal = useCallback( - async (devbox: DevboxListItemType) => { + async (devbox: DevboxListItemTypeV2) => { const defaultCommand = `kubectl exec -it $(kubectl get po -l app.kubernetes.io/name=${devbox.name} -oname) -- sh -c "clear; (bash || ash || sh)"` try { sealosApp.runEvents('openDesktopApp', { @@ -127,164 +127,159 @@ const DevboxList = ({ }, [t, toast] ) - const columns: { title: string - dataIndex?: keyof DevboxListItemType + dataIndex?: keyof DevboxListItemTypeV2 key: string - render?: (item: DevboxListItemType) => JSX.Element + render?: (item: DevboxListItemTypeV2) => JSX.Element }[] = [ - { - title: t('name'), - key: 'name', - render: (item: DevboxListItemType) => { - return ( - - {item.id} { - e.currentTarget.src = '/images/custom.svg' - }} - /> - - {item.name} + { + title: t('name'), + key: 'name', + render: (item) => { + return ( + + {item.id} + + {item.name} + + + ) + } + }, + { + title: t('status'), + key: 'status', + render: (item) => + }, + { + title: t('create_time'), + dataIndex: 'createTime', + key: 'createTime', + render: (item) => { + return {item.createTime} + } + }, + { + title: t('cpu'), + key: 'cpu', + render: (item) => ( + + + + + {item?.usedCpu?.yData[item?.usedCpu?.yData?.length - 1]}% + - - ) - } - }, - { - title: t('status'), - key: 'status', - render: (item: DevboxListItemType) => - }, - { - title: t('create_time'), - dataIndex: 'createTime', - key: 'createTime', - render: (item: DevboxListItemType) => { - return {item.createTime} - } - }, - { - title: t('cpu'), - key: 'cpu', - render: (item: DevboxListItemType) => ( - - - - - {item?.usedCpu?.yData[item?.usedCpu?.yData?.length - 1]}% - - - ) - }, - { - title: t('memory'), - key: 'storage', - render: (item: DevboxListItemType) => ( - - - - - {item?.usedMemory?.yData[item?.usedMemory?.yData?.length - 1]}% - + ) + }, + { + title: t('memory'), + key: 'storage', + render: (item) => ( + + + + + {item?.usedMemory?.yData[item?.usedMemory?.yData?.length - 1]}% + + - - ) - }, - { - title: t('control'), - key: 'control', - render: (item: DevboxListItemType) => ( - - - - - - - } - menuList={[ - { - child: ( - <> - - {t('publish')} - - ), - onClick: () => handleOpenRelease(item) - }, - { - child: ( - <> - - {t('terminal')} - - ), - onClick: () => handleGoToTerminal(item), - menuItemStyle: { - borderBottomLeftRadius: '0px', - borderBottomRightRadius: '0px', - borderBottom: '1px solid #F0F1F6' - } - }, - { - child: ( - <> - - {t('update')} - - ), - onClick: () => router.push(`/devbox/create?name=${item.name}`) - }, - ...(item.status.value === 'Stopped' - ? [ + ) + }, + { + title: t('control'), + key: 'control', + render: (item) => ( + + + + + + + } + menuList={[ + { + child: ( + <> + + {t('publish')} + + ), + onClick: () => handleOpenRelease(item) + }, + { + child: ( + <> + + {t('terminal')} + + ), + onClick: () => handleGoToTerminal(item), + menuItemStyle: { + borderBottomLeftRadius: '0px', + borderBottomRightRadius: '0px', + borderBottom: '1px solid #F0F1F6' + } + }, + { + child: ( + <> + + {t('update')} + + ), + onClick: () => router.push(`/devbox/create?name=${item.name}`) + }, + ...(item.status.value === 'Stopped' + ? [ { child: ( <> @@ -295,10 +290,10 @@ const DevboxList = ({ onClick: () => handleStartDevbox(item) } ] - : []), - // maybe Error or other status,all can restart - ...(item.status.value !== 'Stopped' - ? [ + : []), + // maybe Error or other status,all can restart + ...(item.status.value !== 'Stopped' + ? [ { child: ( <> @@ -309,9 +304,9 @@ const DevboxList = ({ onClick: () => handleRestartDevbox(item) } ] - : []), - ...(item.status.value === 'Running' - ? [ + : []), + ...(item.status.value === 'Running' + ? [ { child: ( <> @@ -322,57 +317,30 @@ const DevboxList = ({ onClick: () => handlePauseDevbox(item) } ] - : []), - { - child: ( - <> - - {t('delete')} - - ), - menuItemStyle: { - _hover: { - color: 'red.600', - bg: 'rgba(17, 24, 36, 0.05)' - } - }, - onClick: () => setDelDevbox(item) - } - ]} - /> - - ) - } - ] - + : []), + { + child: ( + <> + + {t('delete')} + + ), + menuItemStyle: { + _hover: { + color: 'red.600', + bg: 'rgba(17, 24, 36, 0.05)' + } + }, + onClick: () => setDelDevbox(item) + } + ]} + /> + + ) + } + ] return ( - - -
- -
- - {t('devbox_list')} - - - ( {devboxList.length} ) - - - -
+ <> {!!delDevbox && ( )} -
+ ) } diff --git a/frontend/providers/devbox/app/[lang]/(platform)/(home)/components/DevboxListContainer.tsx b/frontend/providers/devbox/app/[lang]/(platform)/(home)/components/DevboxListContainer.tsx new file mode 100644 index 00000000000..de65b83c3a5 --- /dev/null +++ b/frontend/providers/devbox/app/[lang]/(platform)/(home)/components/DevboxListContainer.tsx @@ -0,0 +1,113 @@ +// components/DevboxListContainer.tsx +'use client' +import { useDevboxStore } from '@/stores/devbox' +import { useTemplateStore } from '@/stores/template' +import { DevboxListItemTypeV2 } from '@/types/devbox' +import { isElementInViewport } from '@/utils/tools' +import { Flex, FlexProps } from '@chakra-ui/react' +import { useQuery, useQueryClient } from '@tanstack/react-query' +import { useRouter } from 'next/navigation' +import { useCallback, useEffect, useRef } from 'react' +import DevboxHeader from './DevboxHeader' +import DevboxList from './DevboxList' +import Empty from './Empty' + +function useDevboxList() { + const queryClient = useQueryClient() + const router = useRouter() + const { devboxList, setDevboxList, loadAvgMonitorData, intervalLoadPods } = useDevboxStore() + const { isOpen: templateIsOpen } = useTemplateStore() + const list = useRef(devboxList) + + const { isLoading, refetch: refetchDevboxList } = useQuery( + ['devboxListQuery'], + setDevboxList, + { + onSettled(res) { + if (!res) return + // refreshList(res) + list.current = res + }, + refetchInterval: !templateIsOpen ? 3000 : false, + staleTime: 3000, + enabled:!templateIsOpen, + } + ) + const getViewportDevboxes = (minCount = 3) => { + const doms = document.querySelectorAll('.devboxListItem') + const viewportDomIds = Array.from(doms) + .filter(isElementInViewport) + .map(item => item.getAttribute('data-id')) + + return viewportDomIds.length < minCount + ? devboxList + : devboxList.filter((devbox) => viewportDomIds.includes(devbox.id)) + } + useQuery( + ['intervalLoadPods', devboxList.length], + () => { + + const viewportDevboxList = getViewportDevboxes() + return viewportDevboxList + .filter((devbox) => devbox.status.value !== 'Stopped') + .map((devbox) => intervalLoadPods(devbox.name, false)) + }, + { + refetchOnMount: true, + refetchInterval: !templateIsOpen ? 3000 : false, + staleTime: 3000, + enabled: !isLoading &&!templateIsOpen, + } + ) + + useQuery( + ['loadAvgMonitorData', devboxList.length], + () => { + const viewportDevboxList = getViewportDevboxes() + return viewportDevboxList + .filter((devbox) => devbox.status.value === 'Running') + .map((devbox) => loadAvgMonitorData(devbox.name)) + }, + { + refetchInterval: !templateIsOpen ? 2 * 60 * 1000 : false, + staleTime: 2 * 60 * 1000, + enabled:!isLoading &&!templateIsOpen, + } + ) + // 路由预加载 + useEffect(() => { + router.prefetch('/devbox/detail') + router.prefetch('/devbox/create') + }, [router]) + + return { + list: list.current, + isLoading, + refetchList: useCallback(() => { + queryClient.invalidateQueries(['devboxListQuery']) + }, [queryClient]) + } +} + +export default function DevboxListContainer({ ...props }: FlexProps) { + const { list, isLoading, refetchList } = useDevboxList() + return ( + + + {list.length === 0 && !isLoading ? ( + + ) : ( + + )} + {/* */} + + ) +} \ No newline at end of file diff --git a/frontend/providers/devbox/app/[lang]/(platform)/(home)/components/Empty.tsx b/frontend/providers/devbox/app/[lang]/(platform)/(home)/components/Empty.tsx index 5aa415a7da4..b322cf15d3b 100644 --- a/frontend/providers/devbox/app/[lang]/(platform)/(home)/components/Empty.tsx +++ b/frontend/providers/devbox/app/[lang]/(platform)/(home)/components/Empty.tsx @@ -1,34 +1,25 @@ +import { Box, Flex } from '@chakra-ui/react' import { useTranslations } from 'next-intl' -import { Button, Box } from '@chakra-ui/react' -import { useRouter } from '@/i18n' import MyIcon from '@/components/Icon' +import { useRouter } from '@/i18n' -import styles from './empty.module.scss' const Empty = () => { const router = useRouter() const t = useTranslations() - return ( - {t('devbox_empty')} - - + ) } diff --git a/frontend/providers/devbox/app/[lang]/(platform)/(home)/page.tsx b/frontend/providers/devbox/app/[lang]/(platform)/(home)/page.tsx index 5e852d40690..12608f29ce2 100644 --- a/frontend/providers/devbox/app/[lang]/(platform)/(home)/page.tsx +++ b/frontend/providers/devbox/app/[lang]/(platform)/(home)/page.tsx @@ -1,131 +1,5 @@ -'use client' +import DevboxListContainer from "./components/DevboxListContainer" -import { useRouter } from '@/i18n' -import { useQuery } from '@tanstack/react-query' -import { useCallback, useEffect, useRef, useState } from 'react' - -import Empty from './components/Empty' -import DevboxList from './components/DevboxList' - -import { useLoading } from '@/hooks/useLoading' -import { useDevboxStore } from '@/stores/devbox' -import { isElementInViewport } from '@/utils/tools' -import { DevboxListItemType } from '@/types/devbox' - -const EmptyPage = () => { - const router = useRouter() - const { Loading } = useLoading() - const { devboxList, setDevboxList, loadAvgMonitorData, intervalLoadPods } = useDevboxStore() - - const [_, setFresh] = useState(false) - const list = useRef(devboxList) - - const refreshList = useCallback( - (res = devboxList) => { - list.current = res - setFresh((state) => !state) - return null - }, - [devboxList] - ) - - const { isLoading, refetch: refetchDevboxList } = useQuery(['devboxListQuery'], setDevboxList, { - onSettled(res) { - if (!res) return - refreshList(res) - } - }) - - useQuery( - ['intervalLoadPods', devboxList.length], - () => { - const doms = document.querySelectorAll(`.devboxListItem`) - const viewportDomIds = Array.from(doms) - .filter((item) => isElementInViewport(item)) - .map((item) => item.getAttribute('data-id')) - - const viewportDevboxList = - viewportDomIds.length < 3 - ? devboxList - : devboxList.filter((devbox) => viewportDomIds.includes(devbox.id)) - - return viewportDevboxList - .filter((devbox) => devbox.status.value !== 'Stopped') - .map((devbox) => intervalLoadPods(devbox.name, false)) - }, - { - refetchOnMount: true, - refetchInterval: 3000, - onSettled() { - refreshList() - } - } - ) - - useQuery( - ['refresh'], - () => { - refreshList() - return null - }, - { - refetchInterval: 3000 - } - ) - - const { refetch: refetchAvgMonitorData } = useQuery( - ['loadAvgMonitorData', devboxList.length], - () => { - const doms = document.querySelectorAll('.devboxListItem') - const viewportDomIds = Array.from(doms) - .filter((dom) => isElementInViewport(dom)) - .map((dom) => dom.getAttribute('data-id')) - - const viewportDevboxList = - viewportDomIds.length < 3 - ? devboxList - : devboxList.filter((devbox) => viewportDomIds.includes(devbox.id)) - - // TODO: reference applaunchpad to request rhythmically - return viewportDevboxList - .filter((devbox) => devbox.status.value === 'Running') - .map((devbox) => loadAvgMonitorData(devbox.name)) - }, - { - refetchOnMount: true, - refetchInterval: 2 * 60 * 1000, - onError(err) { - console.log(err) - }, - onSettled() { - refreshList() - } - } - ) - - useEffect(() => { - // router.prefetch('/devbox/detail') - router.prefetch('/devbox/create') - }, [router]) - - return ( - <> - {devboxList.length === 0 && !isLoading ? ( - - ) : ( - <> - { - refetchDevboxList() - refetchAvgMonitorData() - }} - /> - - )} - - - ) +export default function EmptyPage() { + return } - -export default EmptyPage diff --git a/frontend/providers/devbox/app/[lang]/(platform)/devbox/create/components/Form.tsx b/frontend/providers/devbox/app/[lang]/(platform)/devbox/create/components/Form.tsx deleted file mode 100644 index 15142e8adcf..00000000000 --- a/frontend/providers/devbox/app/[lang]/(platform)/devbox/create/components/Form.tsx +++ /dev/null @@ -1,854 +0,0 @@ -'use client' - -import { - Box, - Button, - Center, - Flex, - FormControl, - Grid, - IconButton, - Image, - Input, - Switch, - Text, - useTheme -} from '@chakra-ui/react' -import { throttle } from 'lodash' -import dynamic from 'next/dynamic' -import { customAlphabet } from 'nanoid' -import { useEffect, useState } from 'react' -import { useTranslations } from 'next-intl' -import { UseFormReturn, useFieldArray } from 'react-hook-form' -import { MySelect, MySlider, Tabs, useMessage } from '@sealos/ui' - -import { useRouter } from '@/i18n' -import MyIcon from '@/components/Icon' -import PriceBox from '@/components/PriceBox' -import QuotaBox from '@/components/QuotaBox' - -import { useEnvStore } from '@/stores/env' -import { useDevboxStore } from '@/stores/devbox' -import { useRuntimeStore } from '@/stores/runtime' - -import { ProtocolList } from '@/constants/devbox' -import type { DevboxEditType } from '@/types/devbox' -import { obj2Query } from '@/utils/tools' -import { CpuSlideMarkList, MemorySlideMarkList } from '@/constants/devbox' - -const nanoid = customAlphabet('abcdefghijklmnopqrstuvwxyz', 12) - -export type CustomAccessModalParams = { - publicDomain: string - customDomain: string -} - -const CustomAccessModal = dynamic(() => import('@/components/modals/CustomAccessModal')) - -const Form = ({ - formHook, - pxVal, - isEdit -}: { - formHook: UseFormReturn - pxVal: number - isEdit: boolean -}) => { - const theme = useTheme() - const router = useRouter() - const t = useTranslations() - const { - control, - register, - setValue, - getValues, - formState: { errors } - } = formHook - const { - fields: networks, - append: appendNetworks, - remove: removeNetworks, - update: updateNetworks - } = useFieldArray({ - control, - name: 'networks' - }) - - const { - languageVersionMap, - frameworkVersionMap, - osVersionMap, - languageTypeList, - frameworkTypeList, - osTypeList, - getRuntimeVersionList, - getRuntimeVersionDefault, - getRuntimeDetailLabel - } = useRuntimeStore() - const { env } = useEnvStore() - - const [customAccessModalData, setCustomAccessModalData] = useState() - const navList: { id: string; label: string; icon: string }[] = [ - { - id: 'baseInfo', - label: t('basic_configuration'), - icon: 'formInfo' - }, - { - id: 'network', - label: t('Network Configuration'), - icon: 'network' - } - ] - const { message: toast } = useMessage() - const [activeNav, setActiveNav] = useState(navList[0].id) - const { devboxList } = useDevboxStore() - - // listen scroll and set activeNav - useEffect(() => { - const scrollFn = throttle((e: Event) => { - if (!e.target) return - const doms = navList.map((item) => ({ - dom: document.getElementById(item.id), - id: item.id - })) - - const dom = e.target as HTMLDivElement - const scrollTop = dom.scrollTop - - for (let i = doms.length - 1; i >= 0; i--) { - const offsetTop = doms[i].dom?.offsetTop || 0 - if (scrollTop + 500 >= offsetTop) { - setActiveNav(doms[i].id) - break - } - } - }, 200) - document.getElementById('form-container')?.addEventListener('scroll', scrollFn) - return () => { - document.getElementById('form-container')?.removeEventListener('scroll', scrollFn) - } - // eslint-disable-next-line - }, []) - - if (!formHook) return null - - const Label = ({ - children, - w = 'auto', - ...props - }: { - children: string - w?: number | 'auto' - [key: string]: any - }) => ( - - {children} - - ) - - const boxStyles = { - border: theme.borders.base, - borderRadius: 'lg', - mb: 4, - bg: 'white' - } - - const headerStyles = { - py: 4, - pl: '42px', - borderTopRadius: 'lg', - fontSize: 'xl', - color: 'grayModern.900', - fontWeight: 'bold', - display: 'flex', - alignItems: 'center', - backgroundColor: 'grayModern.50' - } - - return ( - <> - - {/* left sidebar */} - - - router.replace( - `/devbox/create?${obj2Query({ - type: 'yaml' - })}` - ) - } - /> - - {navList.map((item) => ( - { - setActiveNav(item.id) - window.location.hash = item.id - }}> - - - - {item?.label} - - - ))} - - - - - - - - - {/* right content */} - - {/* base info */} - - - - {t('basic_configuration')} - - - {/* Devbox Name */} - - - - - /^[a-zA-Z0-9]([-a-zA-Z0-9]*[a-zA-Z0-9])?(\.[a-zA-Z0-9]([-a-zA-Z0-9]*[a-zA-Z0-9])?)*$/.test( - value - ) || t('devbox_name_invalid') - } - })} - onBlur={(e) => { - const lowercaseValue = e.target.value.toLowerCase() - - setValue('name', lowercaseValue) - const networks = getValues('networks') - networks.forEach((network, i) => { - updateNetworks(i, { - ...network, - networkName: `${lowercaseValue}-${nanoid()}` - }) - }) - }} - /> - - - {/* Runtime Type */} - - - - {/* Language */} - {languageTypeList.length !== 0 && {t('language')}} - - {languageTypeList && - languageTypeList?.map((item) => { - return ( -
{ - if (isEdit) return - const devboxName = getValues('name') - if (!devboxName) { - toast({ - title: t('Please enter the devbox name first'), - status: 'warning' - }) - return - } - setValue('runtimeType', item.id) - setValue( - 'runtimeVersion', - languageVersionMap[getValues('runtimeType')][0].id - ) - setValue( - 'networks', - languageVersionMap[getValues('runtimeType')][0].defaultPorts.map( - (port) => ({ - networkName: `${devboxName}-${nanoid()}`, - portName: nanoid(), - port: port, - protocol: 'HTTP', - openPublicDomain: true, - publicDomain: `${nanoid()}.${env.ingressDomain}`, - customDomain: '' - }) - ) - ) - }}> - {item.id} { - e.currentTarget.src = '/images/custom.svg' - }} - /> - - {item?.label} - -
- ) - })} -
- {/* framework */} - {frameworkTypeList.length !== 0 && {t('framework')}} - - {frameworkTypeList && - frameworkTypeList?.map((item) => { - return ( -
{ - if (isEdit) return - const devboxName = getValues('name') - if (!devboxName) { - toast({ - title: t('Please enter the devbox name first'), - status: 'warning' - }) - return - } - setValue('runtimeType', item.id) - setValue( - 'runtimeVersion', - frameworkVersionMap[getValues('runtimeType')][0].id - ) - setValue( - 'networks', - frameworkVersionMap[getValues('runtimeType')][0].defaultPorts.map( - (port) => ({ - networkName: `${devboxName}-${nanoid()}`, - portName: nanoid(), - port: port, - protocol: 'HTTP', - openPublicDomain: true, - publicDomain: `${nanoid()}.${env.ingressDomain}`, - customDomain: '' - }) - ) - ) - }}> - {item.id} { - e.currentTarget.src = '/images/custom.svg' - }} - /> - - {item?.label} - -
- ) - })} -
- {/* os */} - {osTypeList.length !== 0 && {t('os')}} - - {osTypeList && - osTypeList?.map((item) => { - return ( -
{ - if (isEdit) return - const devboxName = getValues('name') - if (!devboxName) { - toast({ - title: t('Please enter the devbox name first'), - status: 'warning' - }) - return - } - setValue('runtimeType', item.id) - setValue( - 'runtimeVersion', - osVersionMap[getValues('runtimeType')][0].id - ) - setValue( - 'networks', - osVersionMap[getValues('runtimeType')][0].defaultPorts.map( - (port) => ({ - networkName: `${devboxName}-${nanoid()}`, - portName: nanoid(), - port: port, - protocol: 'HTTP', - openPublicDomain: true, - publicDomain: `${nanoid()}.${env.ingressDomain}`, - customDomain: '' - }) - ) - ) - }}> - {item.id} { - e.currentTarget.src = '/images/custom.svg' - }} - /> - - {item?.label} - -
- ) - })} -
-
-
- {/* Runtime Version */} - - - {isEdit ? ( - - ) : ( - { - if (isEdit) return - const devboxName = getValues('name') - if (!devboxName) { - toast({ - title: t('Please enter the devbox name first'), - status: 'warning' - }) - return - } - setValue('runtimeVersion', val) - setValue( - 'networks', - getRuntimeVersionList(getValues('runtimeType'))[0].defaultPorts.map( - (port) => ({ - networkName: `${devboxName}-${nanoid()}`, - portName: nanoid(), - port: port, - protocol: 'HTTP', - openPublicDomain: true, - publicDomain: `${nanoid()}.${env.ingressDomain}`, - customDomain: '' - }) - ) - ) - }} - /> - )} - - {/* CPU */} - - - { - setValue('cpu', CpuSlideMarkList[e].value) - }} - max={CpuSlideMarkList.length - 1} - min={0} - step={1} - /> - - {t('core')} - - - {/* Memory */} - - - { - setValue('memory', MemorySlideMarkList[e].value) - }} - max={MemorySlideMarkList.length - 1} - min={0} - step={1} - /> - -
-
- {/* network */} - - - - {t('Network Configuration')} - - - {networks.length === 0 && ( - - )} - {networks.map((network, i) => ( - - - - {t('Container Port')} - - - {i === networks.length - 1 && networks.length < 5 && ( - - - - )} - - - - {t('Open Public Access')} - - - { - const devboxName = getValues('name') - if (!devboxName) { - toast({ - title: t('Please enter the devbox name first'), - status: 'warning' - }) - return - } - - updateNetworks(i, { - ...getValues('networks')[i], - networkName: network.networkName || `${devboxName}-${nanoid()}`, - protocol: network.protocol || 'HTTP', - openPublicDomain: e.target.checked, - publicDomain: network.publicDomain || `${nanoid()}.${env.ingressDomain}` - }) - }} - /> - - - {network.openPublicDomain && ( - <> - - - - { - updateNetworks(i, { - ...getValues('networks')[i], - protocol: val - }) - }} - /> - - - {network.customDomain ? network.customDomain : network.publicDomain} - - - setCustomAccessModalData({ - publicDomain: network.publicDomain, - customDomain: network.customDomain - }) - }> - {t('Custom Domain')} - - - - - - )} - {networks.length >= 1 && ( - - - } - onClick={() => removeNetworks(i)} - /> - - )} - - ))} - - - {!!customAccessModalData && ( - setCustomAccessModalData(undefined)} - onSuccess={(e) => { - const i = networks.findIndex( - (item) => item.publicDomain === customAccessModalData.publicDomain - ) - if (i === -1) return - updateNetworks(i, { - ...networks[i], - customDomain: e - }) - - setCustomAccessModalData(undefined) - }} - /> - )} -
-
- - ) -} - -export default Form diff --git a/frontend/providers/devbox/app/[lang]/(platform)/devbox/create/components/Header.tsx b/frontend/providers/devbox/app/[lang]/(platform)/devbox/create/components/Header.tsx index 8b3b97a83ef..d58728a90e8 100644 --- a/frontend/providers/devbox/app/[lang]/(platform)/devbox/create/components/Header.tsx +++ b/frontend/providers/devbox/app/[lang]/(platform)/devbox/create/components/Header.tsx @@ -1,14 +1,16 @@ -import JSZip from 'jszip' +import { Box, Button, Flex } from '@chakra-ui/react' import dayjs from 'dayjs' -import React, { useCallback } from 'react' +import JSZip from 'jszip' import { useTranslations } from 'next-intl' -import { Box, Flex, Button } from '@chakra-ui/react' +import { useCallback } from 'react' -import { useRouter } from '@/i18n' import MyIcon from '@/components/Icon' -import { downLoadBlob } from '@/utils/tools' +import { useRouter } from '@/i18n' +import { useEnvStore } from '@/stores/env' import { useGlobalStore } from '@/stores/global' +import { useTemplateStore } from '@/stores/template' import type { YamlItemType } from '@/types/index' +import { downLoadBlob } from '@/utils/tools' const Header = ({ title, @@ -24,7 +26,8 @@ const Header = ({ const router = useRouter() const { lastRoute } = useGlobalStore() const t = useTranslations() - + const { config } = useTemplateStore() + const { env } = useEnvStore() const handleExportYaml = useCallback(async () => { const zip = new JSZip() yamlList.forEach((item) => { @@ -33,10 +36,15 @@ const Header = ({ const res = await zip.generateAsync({ type: 'blob' }) downLoadBlob(res, 'application/zip', `yaml${dayjs().format('YYYYMMDDHHmmss')}.zip`) }, [yamlList]) - return ( - router.replace(lastRoute)}> + { + if (config.lastRoute) { + router.replace(lastRoute) + } else { + router.replace(lastRoute) + }} + }> {t(title)} diff --git a/frontend/providers/devbox/app/[lang]/(platform)/devbox/create/components/form/BasicConfiguration/CpuSelector.tsx b/frontend/providers/devbox/app/[lang]/(platform)/devbox/create/components/form/BasicConfiguration/CpuSelector.tsx new file mode 100644 index 00000000000..fa527cb89c2 --- /dev/null +++ b/frontend/providers/devbox/app/[lang]/(platform)/devbox/create/components/form/BasicConfiguration/CpuSelector.tsx @@ -0,0 +1,28 @@ +import { CpuSlideMarkList } from "@/constants/devbox" +import { DevboxEditTypeV2 } from "@/types/devbox" +import { Box, Flex, FlexProps } from "@chakra-ui/react" +import { MySlider } from "@sealos/ui" +import { useTranslations } from "next-intl" +import { useFormContext } from "react-hook-form" +import Label from "../Label" + +export default function CpuSelector(props: FlexProps) { + const t = useTranslations() + const { watch, setValue } = useFormContext() + return + + { + setValue('cpu', CpuSlideMarkList[e].value) + }} + max={CpuSlideMarkList.length - 1} + min={0} + step={1} + /> + + {t('core')} + + +} \ No newline at end of file diff --git a/frontend/providers/devbox/app/[lang]/(platform)/devbox/create/components/form/BasicConfiguration/DevboxNameInput.tsx b/frontend/providers/devbox/app/[lang]/(platform)/devbox/create/components/form/BasicConfiguration/DevboxNameInput.tsx new file mode 100644 index 00000000000..a4b7084dc81 --- /dev/null +++ b/frontend/providers/devbox/app/[lang]/(platform)/devbox/create/components/form/BasicConfiguration/DevboxNameInput.tsx @@ -0,0 +1,56 @@ +import { DevboxEditTypeV2 } from "@/types/devbox" +import { nanoid } from "@/utils/tools" +import { devboxNameSchema } from "@/utils/vaildate" +import { Flex, FormControl, FormControlProps, Input } from "@chakra-ui/react" +import { useTranslations } from "next-intl" +import { useFieldArray, useFormContext } from "react-hook-form" +import Label from "../Label" + +export default function DevboxNameInput({isEdit, ...props}: {isEdit: boolean} & FormControlProps) { + const t = useTranslations() + const { + register, + setValue, + formState: { errors }, + control + } = useFormContext() + const { + fields: networks, + update: updateNetworks + } = useFieldArray({ + control, + name: 'networks' + }) + return + + + + devboxNameSchema.safeParse(value).success || t('devbox_name_invalid') + } + })} + onBlur={(e) => { + const lowercaseValue = e.target.value.toLowerCase() + setValue('name', lowercaseValue) + networks.forEach((network, i) => { + updateNetworks(i, { + ...network, + networkName: `${lowercaseValue}-${nanoid()}` + }) + }) + }} + /> + + +} \ No newline at end of file diff --git a/frontend/providers/devbox/app/[lang]/(platform)/devbox/create/components/form/BasicConfiguration/MemorySelector.tsx b/frontend/providers/devbox/app/[lang]/(platform)/devbox/create/components/form/BasicConfiguration/MemorySelector.tsx new file mode 100644 index 00000000000..3af3ced3037 --- /dev/null +++ b/frontend/providers/devbox/app/[lang]/(platform)/devbox/create/components/form/BasicConfiguration/MemorySelector.tsx @@ -0,0 +1,25 @@ +import { MemorySlideMarkList } from "@/constants/devbox" +import { DevboxEditTypeV2 } from "@/types/devbox" +import { Flex, FlexProps } from "@chakra-ui/react" +import { MySlider } from "@sealos/ui" +import { useTranslations } from "next-intl" +import { useFormContext } from "react-hook-form" +import Label from "../Label" + +export default function MemorySelector(props: FlexProps) { + const t = useTranslations() + const { watch, setValue } = useFormContext() + return + + { + setValue('memory', MemorySlideMarkList[e].value) + }} + max={MemorySlideMarkList.length - 1} + min={0} + step={1} + /> + +} \ No newline at end of file diff --git a/frontend/providers/devbox/app/[lang]/(platform)/devbox/create/components/form/BasicConfiguration/TemplateRepositoryListNav.tsx b/frontend/providers/devbox/app/[lang]/(platform)/devbox/create/components/form/BasicConfiguration/TemplateRepositoryListNav.tsx new file mode 100644 index 00000000000..b301d0bdb72 --- /dev/null +++ b/frontend/providers/devbox/app/[lang]/(platform)/devbox/create/components/form/BasicConfiguration/TemplateRepositoryListNav.tsx @@ -0,0 +1,98 @@ +import MyIcon from "@/components/Icon"; +import { TemplateState } from "@/constants/template"; +import { useTemplateStore } from "@/stores/template"; +import { Box, Flex, Text } from "@chakra-ui/react"; +import { useTranslations } from "next-intl"; +import { usePathname } from "next/navigation"; + +const TemplateRepositoryListNav = () => { + const t = useTranslations() + const { openTemplateModal, config, isOpen } = useTemplateStore() + const lastRoute = usePathname() + return ( + + {/* All Templates Tab */} + { + openTemplateModal({ + 'templateState': TemplateState.publicTemplate, + lastRoute + }) + }} + > + + + {t("all_templates")} + + + + {/* Divider */} + + + {/* My Templates Tab */} + { + openTemplateModal({ + 'templateState': TemplateState.privateTemplate, + lastRoute + }) + }} + > + + + {t("my_templates")} + + + + ); +}; + +export default TemplateRepositoryListNav; \ No newline at end of file diff --git a/frontend/providers/devbox/app/[lang]/(platform)/devbox/create/components/form/BasicConfiguration/TemplateRepositorySelector/TemplateReposistoryItem.tsx b/frontend/providers/devbox/app/[lang]/(platform)/devbox/create/components/form/BasicConfiguration/TemplateRepositorySelector/TemplateReposistoryItem.tsx new file mode 100644 index 00000000000..05ef402d9c6 --- /dev/null +++ b/frontend/providers/devbox/app/[lang]/(platform)/devbox/create/components/form/BasicConfiguration/TemplateRepositorySelector/TemplateReposistoryItem.tsx @@ -0,0 +1,68 @@ +import { useDevboxStore } from "@/stores/devbox"; +import { DevboxEditTypeV2 } from "@/types/devbox"; +import { Center, Img, Text } from "@chakra-ui/react"; +import { useMessage } from "@sealos/ui"; +import { useTranslations } from "next-intl"; +import { useFormContext } from "react-hook-form"; + +export default function TemplateRepositoryItem({ item, isEdit }: { item: { uid: string, iconId: string, name: string }; isEdit: boolean}) { + const { message: toast } = useMessage() + const t = useTranslations() + const { getValues, setValue, watch } = useFormContext() + const { startedTemplate, setStartedTemplate } = useDevboxStore() + return
{ + if (isEdit) return + const devboxName = getValues('name') + if (!devboxName) { + toast({ + title: t('Please enter the devbox name first'), + status: 'warning' + }) + return + } + if (startedTemplate && startedTemplate.uid !== item.uid) { + setStartedTemplate(undefined) + } + setValue('templateRepositoryUid', item.uid) + }} + > + {item.uid} + + {item.name} + +
+} \ No newline at end of file diff --git a/frontend/providers/devbox/app/[lang]/(platform)/devbox/create/components/form/BasicConfiguration/TemplateRepositorySelector/index.tsx b/frontend/providers/devbox/app/[lang]/(platform)/devbox/create/components/form/BasicConfiguration/TemplateRepositorySelector/index.tsx new file mode 100644 index 00000000000..b0e7ebaa212 --- /dev/null +++ b/frontend/providers/devbox/app/[lang]/(platform)/devbox/create/components/form/BasicConfiguration/TemplateRepositorySelector/index.tsx @@ -0,0 +1,141 @@ +import { getTemplateRepository, listOfficialTemplateRepository } from '@/api/template' +import { TemplateRepositoryKind } from '@/prisma/generated/client' +import { useDevboxStore } from '@/stores/devbox' +import { DevboxEditTypeV2 } from '@/types/devbox' +import { TemplateRepository } from '@/types/template' +import { Box, Flex, VStack } from '@chakra-ui/react' +import { useQuery } from '@tanstack/react-query' +import { useTranslations } from 'next-intl' +import { useEffect, useMemo } from 'react' +import { useFormContext } from 'react-hook-form' +import Label from '../../Label' +import TemplateRepositoryListNav from '../TemplateRepositoryListNav' +import TemplateRepositoryItem from './TemplateReposistoryItem' + +interface TemplateRepositorySelectorProps { + isEdit: boolean +} + +export default function TemplateRepositorySelector({ + isEdit, +}: TemplateRepositorySelectorProps) { + const { startedTemplate, setStartedTemplate } = useDevboxStore() + const { setValue, getValues, watch } = useFormContext() + const t = useTranslations() + const templateRepositoryQuery = useQuery(['list-official-template-repository'], listOfficialTemplateRepository, { + staleTime: Infinity, + cacheTime: 1000 * 60 * 30 + }) + const curTemplateRepositoryUid = watch('templateRepositoryUid') + const curTemplateRepositoryDetail = useQuery(['get-template-repository', curTemplateRepositoryUid], () => { + return getTemplateRepository(curTemplateRepositoryUid) + }, { + enabled: !!isEdit && !!curTemplateRepositoryUid, + }) + + const templateData = useMemo(() => templateRepositoryQuery.data?.templateRepositoryList || [], [templateRepositoryQuery.data]) + + const categorizedData = useMemo(() => { + return templateData.reduce((acc, item) => { + acc[item.kind] = [...(acc[item.kind] || []), item] + return acc + }, { + 'LANGUAGE': [], + 'FRAMEWORK': [], + 'OS': [], + 'CUSTOM': [] + } as Record) + }, [templateData]) + useEffect(() => { + if (!startedTemplate || isEdit) { + return + } + const templateUid = startedTemplate.uid + if (templateData.findIndex((item) => { + return item.uid === templateUid + }) > -1) { + setStartedTemplate(undefined) + } + setValue('templateRepositoryUid', templateUid) + }, [startedTemplate, isEdit]) + + useEffect(() => { + if (startedTemplate || isEdit) { + return + } + if (!(templateRepositoryQuery.isSuccess + && templateData.length > 0 + && templateRepositoryQuery.isFetched)) return + const curTemplateRepositoryUid = getValues('templateRepositoryUid') + const curTemplateRepository = templateData.find((item) => { + return item.uid === curTemplateRepositoryUid + }) + if (!curTemplateRepository) { + const defaultTemplateRepositoryUid = templateData[0].uid + setValue('templateRepositoryUid', defaultTemplateRepositoryUid) + } + }, [templateRepositoryQuery.isSuccess, startedTemplate, templateData, templateRepositoryQuery.isFetched, isEdit]) + + useEffect(() => { + if (!isEdit || !templateRepositoryQuery.isSuccess || !templateData || !curTemplateRepositoryDetail.isSuccess || !curTemplateRepositoryDetail.data) { + return + } + const templateRepository = curTemplateRepositoryDetail.data.templateRepository + // setStartedTemplate(templateRepository) + setValue('templateRepositoryUid', templateRepository.uid) + + if (templateData.findIndex((item) => { + return item.uid === templateRepository.uid + }) === -1) { + setStartedTemplate(templateRepository) + } + }, [curTemplateRepositoryDetail.isSuccess, curTemplateRepositoryDetail.data, curTemplateRepositoryDetail.isFetched, isEdit, templateData, templateRepositoryQuery.isSuccess]) + return ( + + + + + + {!!startedTemplate && + {t('current')} + + + + } + + {/* Language */} + {categorizedData['LANGUAGE'].length !== 0 && {t('language')}} + + {categorizedData['LANGUAGE']?.map((item) => ( + + ))} + + + + {/* Framework */} + {categorizedData['FRAMEWORK'].length !== 0 && {t('framework')}} + + {categorizedData['FRAMEWORK']?.map((item) => ( + + ))} + + + + {/* OS */} + {categorizedData['OS'].length !== 0 && {t('os')}} + + {categorizedData['OS']?.map((item) => ( + + ))} + + + + ) +} + diff --git a/frontend/providers/devbox/app/[lang]/(platform)/devbox/create/components/form/BasicConfiguration/TemplateSelector/index.tsx b/frontend/providers/devbox/app/[lang]/(platform)/devbox/create/components/form/BasicConfiguration/TemplateSelector/index.tsx new file mode 100644 index 00000000000..1ac11c76f91 --- /dev/null +++ b/frontend/providers/devbox/app/[lang]/(platform)/devbox/create/components/form/BasicConfiguration/TemplateSelector/index.tsx @@ -0,0 +1,128 @@ +// RuntimeVersionSelector.tsx +import { listTemplate } from '@/api/template' +import { useEnvStore } from '@/stores/env' +import { DevboxEditTypeV2 } from '@/types/devbox' +import { nanoid } from '@/utils/tools' +import { Flex, Input } from '@chakra-ui/react' +import { MySelect, useMessage } from '@sealos/ui' +import { useQuery } from '@tanstack/react-query' +import { useTranslations } from 'next-intl' +import { useEffect } from 'react' +import { useController, useFormContext } from 'react-hook-form' +import { z } from 'zod' +import Label from '../../Label' + +interface TemplateSelectorProps { + isEdit: boolean +} + +export default function TemplateSelector({ + isEdit, +}: TemplateSelectorProps) { + const { getValues, setValue, watch, control } = useFormContext() + const { env } = useEnvStore() + const { message: toast } = useMessage() + const templateRepositoryUid = watch('templateRepositoryUid') + const isVaildTemplateRepositoryUid = z.string().uuid().safeParse(templateRepositoryUid).success + const templateListQuery = useQuery(['templateList', templateRepositoryUid], () => listTemplate(templateRepositoryUid), { + enabled: isVaildTemplateRepositoryUid, + }) + const templateList = (templateListQuery.data?.templateList || []) + const t = useTranslations() + // const defaultTemplateUid = watch('templateUid') + const menuList = templateList.map(v => ({ label: v.name, value: v.uid })) + // const defaultTemplate = defaultTemplateUid ? templateList.find(t => t.uid === defaultTemplateUid) : templateList[0] + + const { field } = useController({ + control, + name: 'templateUid', + rules: { + required: t('This runtime field is required') + } + }) + const afterUpdateTemplate = (uid: string) => { + const template = templateList.find(v => v.uid === uid)! + setValue( + 'templateConfig', + template.config as string + ) + setValue( + 'image', + template.image + ) + + } + const resetNetwork = () => { + const devboxName = getValues('name') + const config = getValues('templateConfig') + const parsedConfig = + JSON.parse(config as string) as { appPorts: [{ port: number, name: string, protocol: string }] } + setValue( + 'networks', + parsedConfig.appPorts.map( + ({ port }) => ({ + networkName: `${devboxName}-${nanoid()}`, + portName: nanoid(), + port: port, + protocol: 'HTTP', + openPublicDomain: true, + publicDomain: `${nanoid()}.${env.ingressDomain}`, + customDomain: '' + } as const) + ) + ) + } + useEffect(() => { + if (!templateListQuery.isSuccess || !templateList.length || !templateListQuery.isFetched) return + + const curTemplate = templateList.find(t => t.uid === field.value) + const isExist = !!curTemplate + if (!isExist) { + const defaultTemplate = templateList[0] + setValue('templateUid', defaultTemplate.uid) + afterUpdateTemplate(defaultTemplate.uid) + resetNetwork() + } else { + setValue('templateUid', curTemplate.uid) + afterUpdateTemplate(curTemplate.uid) + } + }, [templateListQuery.isSuccess, templateList, templateListQuery.isFetched, isEdit]) + return ( + + + {isEdit ? ( + t.uid === field.value)?.name} + disabled + /> + ) : ( + { + // if (isEdit) return + const devboxName = getValues('name') + if (!devboxName) { + toast({ + title: t('Please enter the devbox name first'), + status: 'warning' + }) + return + } + const oldTemplateUid = getValues('templateUid') + field.onChange(val) + afterUpdateTemplate(val) + if(oldTemplateUid !== val) resetNetwork() + }} + /> + )} + + ) +} \ No newline at end of file diff --git a/frontend/providers/devbox/app/[lang]/(platform)/devbox/create/components/form/BasicConfiguration/index.tsx b/frontend/providers/devbox/app/[lang]/(platform)/devbox/create/components/form/BasicConfiguration/index.tsx new file mode 100644 index 00000000000..6c5c712c3bb --- /dev/null +++ b/frontend/providers/devbox/app/[lang]/(platform)/devbox/create/components/form/BasicConfiguration/index.tsx @@ -0,0 +1,31 @@ +import MyIcon from "@/components/Icon" +import { Box, BoxProps } from "@chakra-ui/react" +import { useTranslations } from "next-intl" +import ConfigurationHeader from "../ConfigurationHeader" +import CpuSelector from "./CpuSelector" +import DevboxNameInput from "./DevboxNameInput" +import MemorySelector from "./MemorySelector" +import TemplateRepositorySelector from "./TemplateRepositorySelector" +import TemplateSelector from "./TemplateSelector" + +export default function BasicConfiguration({ isEdit, ...props }: BoxProps & { isEdit: boolean }) { + const t = useTranslations() + return + + + {t('basic_configuration')} + + + {/* Devbox Name */} + + {/* Template Repository */} + + {/* Runtime Version */} + + {/* CPU */} + + {/* Memory */} + + + +} \ No newline at end of file diff --git a/frontend/providers/devbox/app/[lang]/(platform)/devbox/create/components/form/ConfigurationHeader.tsx b/frontend/providers/devbox/app/[lang]/(platform)/devbox/create/components/form/ConfigurationHeader.tsx new file mode 100644 index 00000000000..d38d46c5e1b --- /dev/null +++ b/frontend/providers/devbox/app/[lang]/(platform)/devbox/create/components/form/ConfigurationHeader.tsx @@ -0,0 +1,19 @@ +import { Box, BoxProps } from "@chakra-ui/react"; +import { ReactNode } from "react"; + +export default function ConfigurationHeader({ children }: { children: ReactNode }) { + const headerStyles: BoxProps = { + py: 4, + pl: '42px', + borderTopRadius: 'lg', + fontSize: 'xl', + color: 'grayModern.900', + fontWeight: 'bold', + display: 'flex', + alignItems: 'center', + backgroundColor: 'grayModern.50' + } + return + {children} + +} \ No newline at end of file diff --git a/frontend/providers/devbox/app/[lang]/(platform)/devbox/create/components/form/Label.tsx b/frontend/providers/devbox/app/[lang]/(platform)/devbox/create/components/form/Label.tsx new file mode 100644 index 00000000000..1804caecf39 --- /dev/null +++ b/frontend/providers/devbox/app/[lang]/(platform)/devbox/create/components/form/Label.tsx @@ -0,0 +1,20 @@ +import { Box, BoxProps } from "@chakra-ui/react" + +const Label = ({ + children, + w = 'auto', + ...props +}: { + children: string + w?: number | 'auto' +} & BoxProps) => ( + + {children} + +) +export default Label \ No newline at end of file diff --git a/frontend/providers/devbox/app/[lang]/(platform)/devbox/create/components/form/NetworkConfiguration/index.tsx b/frontend/providers/devbox/app/[lang]/(platform)/devbox/create/components/form/NetworkConfiguration/index.tsx new file mode 100644 index 00000000000..72e699922eb --- /dev/null +++ b/frontend/providers/devbox/app/[lang]/(platform)/devbox/create/components/form/NetworkConfiguration/index.tsx @@ -0,0 +1,241 @@ +import MyIcon from "@/components/Icon" +import { ProtocolList } from "@/constants/devbox" +import { useEnvStore } from "@/stores/env" +import { DevboxEditTypeV2 } from "@/types/devbox" +import { nanoid } from "@/utils/tools" +import { Box, BoxProps, Button, ButtonProps, Flex, IconButton, Input, Switch, useTheme } from "@chakra-ui/react" +import { MySelect, useMessage } from "@sealos/ui" +import { useTranslations } from "next-intl" +import dynamic from "next/dynamic" +import { useState } from "react" +import { useFieldArray, useFormContext } from "react-hook-form" +import ConfigurationHeader from "../ConfigurationHeader" +// const nanoid = customAlphabet('abcdefghijklmnopqrstuvwxyz', 12) + +export type CustomAccessModalParams = { + publicDomain: string + customDomain: string +} + +const CustomAccessModal = dynamic(() => import('@/components/modals/CustomAccessModal')) +const AppendNetworksButton = (props: ButtonProps) => { + const t = useTranslations() + return () +} +export default function NetworkConfiguration({ isEdit, ...props }: BoxProps & { isEdit: boolean }) { + const { + register, + getValues, + control + } = useFormContext() + const theme = useTheme() + const [customAccessModalData, setCustomAccessModalData] = useState() + const { env } = useEnvStore() + const { + fields: networks, + update: updateNetworks, + append: _appendNetworks, + remove: removeNetworks + } = useFieldArray({ + control, + name: 'networks' + }) + const t = useTranslations() + const { message: toast } = useMessage() + const appendNetworks = () => { + _appendNetworks({ + networkName: '', + portName: nanoid(), + port: 8080, + protocol: 'HTTP', + openPublicDomain: false, + publicDomain: '', + customDomain: '' + }) + } + // const networks = watch('networks') + return <> + + + {t('Network Configuration')} + + + {networks.length === 0 && ( + appendNetworks()} /> + )} + {networks.map((network, i) => ( + + + + {t('Container Port')} + + { + const ports = getValues('networks').map((network, index) => ({ + port: network.port, + index + })); + // 排除当前正在编辑的端口 + const isDuplicate = ports.some( + (item) => item.port === value && item.index !== i + ); + return !isDuplicate || t('The port number cannot be repeated'); + } + } + })} + /> + {i === networks.length - 1 && networks.length < 5 && ( + + appendNetworks()} /> + + )} + + + + {t('Open Public Access')} + + + { + const devboxName = getValues('name') + if (!devboxName) { + toast({ + title: t('Please enter the devbox name first'), + status: 'warning' + }) + return + } + updateNetworks(i, { + ...getValues('networks')[i], + networkName: network.networkName || `${devboxName}-${nanoid()}`, + protocol: network.protocol || 'HTTP', + openPublicDomain: e.target.checked, + publicDomain: network.publicDomain || `${nanoid()}.${env.ingressDomain}` + }) + }} + /> + + + {network.openPublicDomain && ( + <> + + + + { + updateNetworks(i, { + ...getValues('networks')[i], + protocol: val + }) + }} + /> + + + {network.customDomain ? network.customDomain : network.publicDomain!} + + + setCustomAccessModalData({ + publicDomain: network.publicDomain!, + customDomain: network.customDomain! + }) + }> + {t('Custom Domain')} + + + + + + )} + {networks.length >= 1 && ( + + + } + onClick={() => removeNetworks(i)} + /> + + )} + + ))} + + + {!!customAccessModalData && ( + setCustomAccessModalData(undefined)} + onSuccess={(e) => { + const i = networks.findIndex( + (item) => item.publicDomain === customAccessModalData.publicDomain + ) + if (i === -1) return + updateNetworks(i, { + ...networks[i], + customDomain: e + }) + setCustomAccessModalData(undefined) + }} + /> + )} +} \ No newline at end of file diff --git a/frontend/providers/devbox/app/[lang]/(platform)/devbox/create/components/form/index.tsx b/frontend/providers/devbox/app/[lang]/(platform)/devbox/create/components/form/index.tsx new file mode 100644 index 00000000000..edfcd924487 --- /dev/null +++ b/frontend/providers/devbox/app/[lang]/(platform)/devbox/create/components/form/index.tsx @@ -0,0 +1,188 @@ +'use client' + +import { + Box, + Flex, + Grid, + useTheme +} from '@chakra-ui/react' +import { Tabs } from '@sealos/ui' +import { throttle } from 'lodash' +import { useTranslations } from 'next-intl' +import { useEffect, useState } from 'react' +import { useFormContext } from 'react-hook-form' + +import MyIcon from '@/components/Icon' +import PriceBox from '@/components/PriceBox' +import QuotaBox from '@/components/QuotaBox' +import { useRouter } from '@/i18n' + +import { useDevboxStore } from '@/stores/devbox' + +import type { DevboxEditTypeV2 } from '@/types/devbox' +import { obj2Query } from '@/utils/tools' +import BasicConfiguration from './BasicConfiguration' +import NetworkConfiguration from './NetworkConfiguration' + +const Form = ({ + pxVal, + isEdit +}: { + pxVal: number + isEdit: boolean +}) => { + const theme = useTheme() + const router = useRouter() + const t = useTranslations() + const { + watch + } = useFormContext() + const navList: { id: string; label: string; icon: string }[] = [ + { + id: 'baseInfo', + label: t('basic_configuration'), + icon: 'formInfo' + }, + { + id: 'network', + label: t('Network Configuration'), + icon: 'network' + } + ] + const [activeNav, setActiveNav] = useState(navList[0].id) + const { devboxList } = useDevboxStore() + + // listen scroll and set activeNav + useEffect(() => { + const scrollFn = throttle((e: Event) => { + if (!e.target) return + const doms = navList.map((item) => ({ + dom: document.getElementById(item.id), + id: item.id + })) + + const dom = e.target as HTMLDivElement + const scrollTop = dom.scrollTop + for (let i = doms.length - 1; i >= 0; i--) { + const offsetTop = doms[i].dom?.offsetTop || 0 + + if (scrollTop + 500 >= offsetTop) { + setActiveNav(doms[i].id) + break + } + } + }, 200) + document.getElementById('form-container')?.addEventListener('scroll', scrollFn) + return () => { + document.getElementById('form-container')?.removeEventListener('scroll', scrollFn) + } + // eslint-disable-next-line + }, []) + + + const boxStyles = { + border: theme.borders.base, + borderRadius: 'lg', + mb: 4, + bg: 'white' + } + + return ( + + {/* left sidebar */} + + + router.replace( + `/devbox/create?${obj2Query({ + type: 'yaml' + })}` + ) + } + /> + + {navList.map((item) => ( + { + setActiveNav(item.id) + window.location.hash = item.id + }}> + + + + {item.label} + + + ))} + + + + + + + + + {/* right content */} + + {/* base info */} + + {/* network */} + + + + ) +} + +export default Form diff --git a/frontend/providers/devbox/app/[lang]/(platform)/devbox/create/page.tsx b/frontend/providers/devbox/app/[lang]/(platform)/devbox/create/page.tsx index f005fa3e8c5..e0c6940625b 100644 --- a/frontend/providers/devbox/app/[lang]/(platform)/devbox/create/page.tsx +++ b/frontend/providers/devbox/app/[lang]/(platform)/devbox/create/page.tsx @@ -1,127 +1,65 @@ 'use client' import { useRouter } from '@/i18n' -import dynamic from 'next/dynamic' -import debounce from 'lodash/debounce' -import { useMessage } from '@sealos/ui' -import { customAlphabet } from 'nanoid' -import { useForm } from 'react-hook-form' import { Box, Flex } from '@chakra-ui/react' -import { useTranslations } from 'next-intl' +import { useMessage } from '@sealos/ui' import { useQuery } from '@tanstack/react-query' +import { useTranslations } from 'next-intl' +import dynamic from 'next/dynamic' import { useSearchParams } from 'next/navigation' import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { FormProvider, useForm } from 'react-hook-form' -import Form from './components/Form' -import Yaml from './components/Yaml' +import Form from './components/form' import Header from './components/Header' +import Yaml from './components/Yaml' import type { YamlItemType } from '@/types' -import type { DevboxEditType, DevboxKindsType, ProtocolType } from '@/types/devbox' +import type { DevboxEditType, DevboxEditTypeV2, DevboxKindsType } from '@/types/devbox' import { useConfirm } from '@/hooks/useConfirm' import { useLoading } from '@/hooks/useLoading' +import { useDevboxStore } from '@/stores/devbox' import { useEnvStore } from '@/stores/env' +import { useGlobalStore } from '@/stores/global' import { useIDEStore } from '@/stores/ide' import { useUserStore } from '@/stores/user' -import { useDevboxStore } from '@/stores/devbox' -import { useGlobalStore } from '@/stores/global' -import { useRuntimeStore } from '@/stores/runtime' -import { patchYamlList } from '@/utils/tools' import { createDevbox, updateDevbox } from '@/api/devbox' -import { json2Devbox, json2Ingress, json2Service } from '@/utils/json2Yaml' -import { - FrameworkTypeEnum, - LanguageTypeEnum, - OSTypeEnum, - defaultDevboxEditValue, - editModeMap -} from '@/constants/devbox' +import { defaultDevboxEditValueV2, editModeMap } from '@/constants/devbox' +import { useTemplateStore } from '@/stores/template' +import { generateYamlList } from '@/utils/json2Yaml' +import { patchYamlList } from '@/utils/tools' +import { debounce } from 'lodash' const ErrorModal = dynamic(() => import('@/components/modals/ErrorModal')) - -const nanoid = customAlphabet('abcdefghijklmnopqrstuvwxyz', 12) - const DevboxCreatePage = () => { + const {env } = useEnvStore() + const generateDefaultYamlList = () => generateYamlList(defaultDevboxEditValueV2, env) const router = useRouter() const t = useTranslations() - const searchParams = useSearchParams() const { message: toast } = useMessage() - - const { env } = useEnvStore() const { addDevboxIDE } = useIDEStore() const { checkQuotaAllow } = useUserStore() const { setDevboxDetail, devboxList } = useDevboxStore() - const { runtimeNamespaceMap, languageVersionMap, frameworkVersionMap, osVersionMap } = - useRuntimeStore() const crOldYamls = useRef([]) const formOldYamls = useRef([]) - const oldDevboxEditData = useRef() + const oldDevboxEditData = useRef() const { Loading, setIsLoading } = useLoading() const [errorMessage, setErrorMessage] = useState('') - const [forceUpdate, setForceUpdate] = useState(false) const [yamlList, setYamlList] = useState([]) const tabType = searchParams.get('type') || 'form' const devboxName = searchParams.get('name') || '' - const runtime = searchParams.get('runtime') || '' - - const formData2Yamls = (data: DevboxEditType) => [ - { - filename: 'devbox.yaml', - value: json2Devbox(data, runtimeNamespaceMap, env.devboxAffinityEnable, env.squashEnable) - }, - ...(data.networks.length > 0 - ? [ - { - filename: 'service.yaml', - value: json2Service(data) - } - ] - : []), - ...(data.networks.find((item) => item.openPublicDomain) - ? [ - { - filename: 'ingress.yaml', - value: json2Ingress(data, env.ingressSecret) - } - ] - : []) - ] - - const defaultEdit = { - ...defaultDevboxEditValue, - runtimeType: runtime || LanguageTypeEnum.go, - runtimeVersion: runtime - ? languageVersionMap[runtime as LanguageTypeEnum]?.[0]?.id || - frameworkVersionMap[runtime as FrameworkTypeEnum]?.[0]?.id || - osVersionMap[runtime as OSTypeEnum]?.[0]?.id - : languageVersionMap[LanguageTypeEnum.go]?.[0]?.id, - networks: ( - languageVersionMap[runtime as LanguageTypeEnum]?.[0]?.defaultPorts || - frameworkVersionMap[runtime as FrameworkTypeEnum]?.[0]?.defaultPorts || - osVersionMap[runtime as OSTypeEnum]?.[0]?.defaultPorts || - languageVersionMap[LanguageTypeEnum.go]?.[0]?.defaultPorts - ).map((port) => ({ - networkName: `${defaultDevboxEditValue.name}-${nanoid()}`, - portName: nanoid(), - port: port, - protocol: 'HTTP' as ProtocolType, - openPublicDomain: true, - publicDomain: `${nanoid()}.${env.ingressDomain}`, - customDomain: '' - })) - } // NOTE: need to explain why this is needed // fix a bug: searchParams will disappear when go into this page const [captureDevboxName, setCaptureDevboxName] = useState('') - + const { updateTemplateModalConfig, config: templateConfig } = useTemplateStore() useEffect(() => { const name = searchParams.get('name') if (name) { @@ -150,84 +88,43 @@ const DevboxCreatePage = () => { return val }, [screenWidth]) - const generateYamlList = (data: DevboxEditType) => { - return [ - { - filename: 'devbox.yaml', - value: json2Devbox(data, runtimeNamespaceMap, env.devboxAffinityEnable, env.squashEnable) - }, - ...(data.networks.length > 0 - ? [ - { - filename: 'service.yaml', - value: json2Service(data) - } - ] - : []), - ...(data.networks.find((item) => item.openPublicDomain) - ? [ - { - filename: 'ingress.yaml', - value: json2Ingress(data, env.ingressSecret) - } - ] - : []) - ] - } - - const formHook = useForm({ - defaultValues: defaultEdit + const formHook = useForm({ + defaultValues: defaultDevboxEditValueV2 }) - // eslint-disable-next-line react-hooks/exhaustive-deps - const formOnchangeDebounce = useCallback( - debounce((data: DevboxEditType) => { - try { - setYamlList(generateYamlList(data)) - } catch (error) { - console.log(error) - } - }, 200), + // updateyamlList every time yamlList change + const debouncedUpdateYaml = useMemo( + () => + debounce((data: DevboxEditTypeV2, env) => { + try { + const newYamlList = generateYamlList(data, env) + setYamlList(newYamlList) + } catch (error) { + console.error('Failed to generate yaml:', error) + } + }, 300), [] ) - // watch form change, compute new yaml - formHook.watch((data) => { - data && formOnchangeDebounce(data as DevboxEditType) - setForceUpdate(!forceUpdate) - }) + // 监听表单变化 + useEffect(() => { + const subscription = formHook.watch((value) => { + if (value) { + debouncedUpdateYaml(value as DevboxEditTypeV2, env) + } + }) + return () => { + subscription.unsubscribe() + debouncedUpdateYaml.cancel() + } + }, [formHook, debouncedUpdateYaml, env]) + useQuery( ['initDevboxCreateData'], () => { if (!devboxName) { - setYamlList([ - { - filename: 'devbox.yaml', - value: json2Devbox( - defaultEdit, - runtimeNamespaceMap, - env.devboxAffinityEnable, - env.squashEnable - ) - }, - ...(defaultEdit.networks.length > 0 - ? [ - { - filename: 'service.yaml', - value: json2Service(defaultEdit) - } - ] - : []), - ...(defaultEdit.networks.find((item) => item.openPublicDomain) - ? [ - { - filename: 'ingress.yaml', - value: json2Ingress(defaultEdit, env.ingressSecret) - } - ] - : []) - ]) + setYamlList(generateDefaultYamlList()) return null } setIsLoading(true) @@ -239,8 +136,8 @@ const DevboxCreatePage = () => { return } oldDevboxEditData.current = res - formOldYamls.current = formData2Yamls(res) - crOldYamls.current = generateYamlList(res) as DevboxKindsType[] + formOldYamls.current = generateYamlList(res, env) + crOldYamls.current = generateYamlList(res, env) as DevboxKindsType[] formHook.reset(res) }, onError(err) { @@ -254,14 +151,12 @@ const DevboxCreatePage = () => { } } ) - - const submitSuccess = async (formData: DevboxEditType) => { + const submitSuccess = async (formData: DevboxEditTypeV2) => { setIsLoading(true) - try { // quote check const quoteCheckRes = checkQuotaAllow( - { ...formData, nodeports: devboxList.length + 1 } as DevboxEditType & { + { ...formData, nodeports: devboxList.length + 1 } as DevboxEditTypeV2 & { nodeports: number }, { @@ -280,45 +175,57 @@ const DevboxCreatePage = () => { isClosable: true }) } - const parsedNewYamlList = yamlList.map((item) => item.value) - const parsedOldYamlList = formOldYamls.current.map((item) => item.value) - - const areYamlListsEqual = - new Set(parsedNewYamlList).size === new Set(parsedOldYamlList).size && - [...new Set(parsedNewYamlList)].every((item) => new Set(parsedOldYamlList).has(item)) - if (areYamlListsEqual) { - setIsLoading(false) - return toast({ - status: 'info', - title: t('No changes detected'), - duration: 5000, - isClosable: true - }) - } - // create or update + // update if (isEdit) { + const yamlList = generateYamlList(formData, env) + setYamlList(yamlList) + const parsedNewYamlList = yamlList.map((item) => item.value) + const parsedOldYamlList = formOldYamls.current.map((item) => item.value) + const areYamlListsEqual = + new Set(parsedNewYamlList).size === new Set(parsedOldYamlList).size && + [...new Set(parsedNewYamlList)].every((item) => new Set(parsedOldYamlList).has(item)) + if (areYamlListsEqual) { + setIsLoading(false) + return toast({ + status: 'info', + title: t('No changes detected'), + duration: 5000, + isClosable: true + }) + } + if(!parsedNewYamlList) { + // prevent empty yamlList + return setErrorMessage(t('submit_form_error')) + } const patch = patchYamlList({ parsedOldYamlList: parsedOldYamlList, parsedNewYamlList: parsedNewYamlList, originalYamlList: crOldYamls.current }) - await updateDevbox({ patch, devboxName: formData.name }) } else { - await createDevbox({ devboxForm: formData, runtimeNamespaceMap }) + await createDevbox({ devboxForm: formData }) } - addDevboxIDE('cursor', formData.name) + addDevboxIDE('vscode', formData.name) toast({ title: t(applySuccess), status: 'success' }) + updateTemplateModalConfig({ + ...templateConfig, + lastRoute + }) router.push(lastRoute) } catch (error) { - console.error(error) - setErrorMessage(JSON.stringify(error)) + console.log('error', error) + if (error instanceof String && error.includes('402')) { + setErrorMessage(t('outstanding_tips')) + } + else + setErrorMessage(JSON.stringify(error)) } setIsLoading(false) } @@ -343,8 +250,8 @@ const DevboxCreatePage = () => { }) }, [formHook.formState.errors, toast, t]) - return ( - <> + return (<> + { /> {tabType === 'form' ? ( -
+ ) : ( )} - - - {!!errorMessage && ( - setErrorMessage('')} /> - )} - + + + + + {!!errorMessage && ( + setErrorMessage('')} /> + )} + ) } diff --git a/frontend/providers/devbox/app/[lang]/(platform)/devbox/detail/[name]/components/BasicInfo.tsx b/frontend/providers/devbox/app/[lang]/(platform)/devbox/detail/[name]/components/BasicInfo.tsx index e59705e3f6d..c24a8755923 100644 --- a/frontend/providers/devbox/app/[lang]/(platform)/devbox/detail/[name]/components/BasicInfo.tsx +++ b/frontend/providers/devbox/app/[lang]/(platform)/devbox/detail/[name]/components/BasicInfo.tsx @@ -1,15 +1,14 @@ +import { Box, Flex, Image, Spinner, Text, Tooltip } from '@chakra-ui/react' import { useMessage } from '@sealos/ui' import { useTranslations } from 'next-intl' -import React, { useCallback, useState } from 'react' -import { Box, Text, Flex, Image, Spinner, Tooltip } from '@chakra-ui/react' +import { useCallback, useState } from 'react' import MyIcon from '@/components/Icon' import { DevboxDetailType } from '@/types/devbox' -import { useEnvStore } from '@/stores/env' import { useDevboxStore } from '@/stores/devbox' -import { useRuntimeStore } from '@/stores/runtime' +import { useEnvStore } from '@/stores/env' const BasicInfo = () => { const t = useTranslations() @@ -17,12 +16,12 @@ const BasicInfo = () => { const { env } = useEnvStore() const { devboxDetail } = useDevboxStore() - const { getRuntimeDetailLabel } = useRuntimeStore() + // const { getRuntimeDetailLabel } = useRuntimeStore() const [loading, setLoading] = useState(false) - + const handleCopySSHCommand = useCallback(() => { - const sshCommand = `ssh -i yourPrivateKeyPath ${devboxDetail?.sshConfig?.sshUser}@${env.sealosDomain} -p ${devboxDetail.sshPort}` + const sshCommand = `ssh -i yourPrivateKeyPath ${devboxDetail?.sshConfig?.sshUser}@${env.sealosDomain} -p ${devboxDetail?.sshPort}` navigator.clipboard.writeText(sshCommand).then(() => { toast({ title: t('copy_success'), @@ -31,7 +30,7 @@ const BasicInfo = () => { isClosable: true }) }) - }, [devboxDetail?.sshConfig?.sshUser, devboxDetail.sshPort, env.sealosDomain, toast, t]) + }, [devboxDetail?.sshConfig?.sshUser, devboxDetail?.sshPort, env.sealosDomain, toast, t]) const handleDownloadConfig = useCallback( async (config: DevboxDetailType['sshConfig']) => { @@ -44,7 +43,7 @@ const BasicInfo = () => { const a = document.createElement('a') a.style.display = 'none' a.href = url - a.download = devboxDetail.name + a.download = devboxDetail?.name || '' document.body.appendChild(a) a.click() window.URL.revokeObjectURL(url) @@ -52,7 +51,7 @@ const BasicInfo = () => { setLoading(false) }, - [devboxDetail] + [devboxDetail?.name] ) return ( @@ -75,11 +74,11 @@ const BasicInfo = () => { ml={2} width={'20px'} height={'20px'} - alt={devboxDetail?.runtimeType} - src={`/images/${devboxDetail?.runtimeType}.svg`} onError={(e) => { e.currentTarget.src = '/images/custom.svg' }} + alt={devboxDetail?.iconId} + src={`/images/${devboxDetail?.iconId}.svg`} /> @@ -106,8 +105,15 @@ const BasicInfo = () => { {t('start_runtime')} - - {getRuntimeDetailLabel(devboxDetail?.runtimeType, devboxDetail?.runtimeVersion)} + + { + // getRuntimeDetailLabel(devboxDetail?., devboxDetail?.runtimeVersion) + `${devboxDetail?.templateRepositoryName}-${devboxDetail?.templateName}` + } @@ -124,7 +130,7 @@ const BasicInfo = () => { CPU Limit - {devboxDetail?.cpu / 1000} Core + {(devboxDetail?.cpu || 0) / 1000} Core @@ -132,7 +138,7 @@ const BasicInfo = () => { Memory Limit - {devboxDetail?.memory / 1024} G + {(devboxDetail?.memory || 0) / 1024} G @@ -174,7 +180,7 @@ const BasicInfo = () => { _hover={{ color: 'blue.500' }} onClick={handleCopySSHCommand} w={'full'}> - {`ssh -i yourPrivateKeyPath ${devboxDetail?.sshConfig?.sshUser}@${env.sealosDomain} -p ${devboxDetail.sshPort}`} + {`ssh -i yourPrivateKeyPath ${devboxDetail?.sshConfig?.sshUser}@${env.sealosDomain} -p ${devboxDetail?.sshPort}`} @@ -267,9 +273,6 @@ const BasicInfo = () => { h={'16px'} color={'grayModern.600'} mt={'1px'} - onClick={() => { - console.log('click') - }} /> diff --git a/frontend/providers/devbox/app/[lang]/(platform)/devbox/detail/[name]/components/Header.tsx b/frontend/providers/devbox/app/[lang]/(platform)/devbox/detail/[name]/components/Header.tsx index 7715a12ab2e..ec53313842d 100644 --- a/frontend/providers/devbox/app/[lang]/(platform)/devbox/detail/[name]/components/Header.tsx +++ b/frontend/providers/devbox/app/[lang]/(platform)/devbox/detail/[name]/components/Header.tsx @@ -1,19 +1,19 @@ +import { Box, Button, Flex } from '@chakra-ui/react' import { useMessage } from '@sealos/ui' import { useTranslations } from 'next-intl' -import { Flex, Button, Box } from '@chakra-ui/react' import { Dispatch, useCallback, useMemo, useState } from 'react' +import { pauseDevbox, restartDevbox, startDevbox } from '@/api/devbox' import { useRouter } from '@/i18n' import { useDevboxStore } from '@/stores/devbox' import { useGlobalStore } from '@/stores/global' -import { pauseDevbox, restartDevbox, startDevbox } from '@/api/devbox' -import { DevboxDetailType } from '@/types/devbox' +import { DevboxDetailTypeV2 } from '@/types/devbox' +import DevboxStatusTag from '@/components/DevboxStatusTag' import MyIcon from '@/components/Icon' import IDEButton from '@/components/IDEButton' import DelModal from '@/components/modals/DelModal' -import DevboxStatusTag from '@/components/DevboxStatusTag' import { sealosApp } from 'sealos-desktop-sdk/app' import { useQuery } from '@tanstack/react-query' @@ -33,7 +33,7 @@ const Header = ({ const { devboxDetail, setDevboxList } = useDevboxStore() const { screenWidth, setLoading } = useGlobalStore() - const [delDevbox, setDelDevbox] = useState(null) + const [delDevbox, setDelDevbox] = useState(null) const isBigButton = useMemo(() => screenWidth > 1000, [screenWidth]) const { refetch: refetchDevboxList } = useQuery(['devboxListQuery'], setDevboxList, { @@ -43,7 +43,7 @@ const Header = ({ }) const handlePauseDevbox = useCallback( - async (devbox: DevboxDetailType) => { + async (devbox: DevboxDetailTypeV2) => { try { setLoading(true) await pauseDevbox({ devboxName: devbox.name }) @@ -64,7 +64,7 @@ const Header = ({ [refetchDevboxDetail, setLoading, t, toast] ) const handleRestartDevbox = useCallback( - async (devbox: DevboxDetailType) => { + async (devbox: DevboxDetailTypeV2) => { try { setLoading(true) await restartDevbox({ devboxName: devbox.name }) @@ -85,7 +85,7 @@ const Header = ({ [setLoading, t, toast, refetchDevboxDetail] ) const handleStartDevbox = useCallback( - async (devbox: DevboxDetailType) => { + async (devbox: DevboxDetailTypeV2) => { try { setLoading(true) await startDevbox({ devboxName: devbox.name }) @@ -106,7 +106,7 @@ const Header = ({ [setLoading, t, toast, refetchDevboxDetail] ) const handleGoToTerminal = useCallback( - async (devbox: DevboxDetailType) => { + async (devbox: DevboxDetailTypeV2) => { const defaultCommand = `kubectl exec -it $(kubectl get po -l app.kubernetes.io/name=${devbox.name} -oname) -- sh -c "clear; (bash || ash || sh)"` try { sealosApp.runEvents('openDesktopApp', { @@ -126,6 +126,7 @@ const Header = ({ }, [t, toast] ) + if (!devboxDetail) return null return ( {/* left back button and title */} @@ -143,7 +144,7 @@ const Header = ({ {/* detail button */} - + {!isLargeScreen && ( - - - - } - menuList={[ - { - child: ( - <> - - {t('edit')} - - ), - onClick: () => { - setCurrentVersion(item) - onOpenEdit() - } - }, - { - child: ( - <> - - {t('delete')} - - ), - menuItemStyle: { - _hover: { - color: 'red.600', - bg: 'rgba(17, 24, 36, 0.05)' + ) + }, + { + title: t('status'), + key: 'status', + render: (item: DevboxVersionListItemType) => ( + + ) + }, + { + title: t('create_time'), + dataIndex: 'createTime', + key: 'createTime', + render: (item: DevboxVersionListItemType) => { + return {item.createTime} + } + }, + { + title: t('version_description'), + key: 'description', + render: (item: DevboxVersionListItemType) => ( + + + {item.description} + + + ) + }, + { + title: t('control'), + key: 'control', + render: (item: DevboxVersionListItemType) => ( + + + + + + } + menuList={[ + { + child: ( + <> + + {t('edit')} + + ), + onClick: () => { + setCurrentVersion(item) + onOpenEdit() + } + }, + { + child: ( + <> + + {t('convert_to_runtime')} + + ), + onClick: () => { + setCurrentVersion(item) + // onOpenEdit() + // openTemplateModal({templateState: }) + if (templateRepositoryList.length > 0) { + selectTemplalteModalHandler.onOpen() + } else { + createTemplateModalHandler.onOpen() + } } }, - onClick: () => openConfirm(() => handleDelDevboxVersion(item.name))() + { + child: ( + <> + + {t('delete')} + + ), + menuItemStyle: { + _hover: { + color: 'red.600', + bg: 'rgba(17, 24, 36, 0.05)' + } + }, + onClick: () => openConfirm(() => handleDelDevboxVersion(item.name))() + } + ] } - ]} - /> - - ) - } - ] - + /> + + ) + } + ] return ( { ) : ( - + )} {!!currentVersion && ( { onClose={onCloseEdit} /> )} - {!!onOpenRelease && ( + {!!onOpenRelease && !!devbox && ( { @@ -329,13 +378,32 @@ const Version = () => { {!!onOpenSelectApp && ( setOnOpenSelectApp(false)} onClose={() => setOnOpenSelectApp(false)} /> )} + + {templateRepositoryList.length > 0 && { + const repo = templateRepositoryList.find((item) => item.uid === uid) + setUpdateTemplateRepo(repo || null) + updateTemplateModalHandler.onOpen() + }} + templateRepositoryList={templateRepositoryList} + isOpen={selectTemplalteModalHandler.isOpen} onClose={ + selectTemplalteModalHandler.onClose + } />} + {!!updateTemplateRepo && } + ) } diff --git a/frontend/providers/devbox/app/[lang]/(platform)/devbox/detail/[name]/page.tsx b/frontend/providers/devbox/app/[lang]/(platform)/devbox/detail/[name]/page.tsx index 1d4e6aec1c2..7f64bf8a4bd 100644 --- a/frontend/providers/devbox/app/[lang]/(platform)/devbox/detail/[name]/page.tsx +++ b/frontend/providers/devbox/app/[lang]/(platform)/devbox/detail/[name]/page.tsx @@ -1,17 +1,17 @@ 'use client' -import { useMemo, useState } from 'react' -import { useQuery } from '@tanstack/react-query' import { Box, Flex } from '@chakra-ui/react' +import { useQuery } from '@tanstack/react-query' +import { useMemo, useState } from 'react' +import { useLoading } from '@/hooks/useLoading' +import BasicInfo from './components/BasicInfo' import Header from './components/Header' -import Version from './components/Version' import MainBody from './components/MainBody' -import BasicInfo from './components/BasicInfo' -import { useLoading } from '@/hooks/useLoading' +import Version from './components/Version' -import { useEnvStore } from '@/stores/env' import { useDevboxStore } from '@/stores/devbox' +import { useEnvStore } from '@/stores/env' import { useGlobalStore } from '@/stores/global' const DevboxDetailPage = ({ params }: { params: { name: string } }) => { @@ -27,8 +27,8 @@ const DevboxDetailPage = ({ params }: { params: { name: string } }) => { const [initialized, setInitialized] = useState(false) const isLargeScreen = useMemo(() => screenWidth > 1280, [screenWidth]) - const { refetch } = useQuery( - ['initDevboxDetail'], + const { refetch, data } = useQuery( + ['initDevboxDetail',], () => setDevboxDetail(devboxName, env.sealosDomain), { onSettled() { @@ -36,7 +36,6 @@ const DevboxDetailPage = ({ params }: { params: { name: string } }) => { } } ) - useQuery( ['devbox-detail-pod'], () => { @@ -44,6 +43,7 @@ const DevboxDetailPage = ({ params }: { params: { name: string } }) => { return intervalLoadPods(devboxName, true) }, { + enabled: !devboxDetail?.isPause, refetchOnMount: true, refetchInterval: 3000 } @@ -57,14 +57,13 @@ const DevboxDetailPage = ({ params }: { params: { name: string } }) => { }, { refetchOnMount: true, - refetchInterval: 2 * 60 * 1000 + refetchInterval: 2 * 60 * 1000, } ) - return ( - - - {devboxDetail !== null && initialized && ( + + + {devboxDetail && initialized && ( <>
{ {...(isLargeScreen ? {} : { - position: 'absolute', - left: 0, - boxShadow: '7px 4px 12px rgba(165, 172, 185, 0.25)', - transform: `translateX(${showSlider ? '0' : '-500'}px)` - })}> + position: 'absolute', + left: 0, + boxShadow: '7px 4px 12px rgba(165, 172, 185, 0.25)', + transform: `translateX(${showSlider ? '0' : '-500'}px)` + })}> { /> )} - )} - + )} + ) } diff --git a/frontend/providers/devbox/app/[lang]/(platform)/layout.tsx b/frontend/providers/devbox/app/[lang]/(platform)/layout.tsx index 287949f07ae..cbac0a74a99 100644 --- a/frontend/providers/devbox/app/[lang]/(platform)/layout.tsx +++ b/frontend/providers/devbox/app/[lang]/(platform)/layout.tsx @@ -1,31 +1,33 @@ 'use client' +import { usePathname, useRouter } from '@/i18n' import throttle from 'lodash/throttle' +import { useSearchParams } from 'next/navigation' import { useEffect, useState } from 'react' import { EVENT_NAME } from 'sealos-desktop-sdk' -import { usePathname, useRouter } from '@/i18n' -import { useSearchParams } from 'next/navigation' import { createSealosApp, sealosApp } from 'sealos-desktop-sdk/app' +// import { useConfirm } from '@/hooks/useConfirm' import { useLoading } from '@/hooks/useLoading' -import { useConfirm } from '@/hooks/useConfirm' import { useEnvStore } from '@/stores/env' import { useGlobalStore } from '@/stores/global' import { usePriceStore } from '@/stores/price' -import { useRuntimeStore } from '@/stores/runtime' -import { getLangStore, setLangStore } from '@/utils/cookie' -import QueryProvider from '@/components/providers/MyQueryProvider' +import { initUser } from '@/api/template' import ChakraProvider from '@/components/providers/MyChakraProvider' import RouteHandlerProvider from '@/components/providers/MyRouteHandlerProvider' +import { useConfirm } from '@/hooks/useConfirm' +import { getLangStore, setLangStore } from '@/utils/cookie' +import { cleanSession, setSessionToSessionStorage } from '@/utils/user' +import { useQueryClient } from '@tanstack/react-query' +import TemplateModal from './template/TemplateModal' export default function PlatformLayout({ children }: { children: React.ReactNode }) { const router = useRouter() const pathname = usePathname() const { Loading } = useLoading() const { setEnv, env } = useEnvStore() - const { setRuntime } = useRuntimeStore() const searchParams = useSearchParams() const { setSourcePrice } = usePriceStore() const [refresh, setRefresh] = useState(false) @@ -34,36 +36,46 @@ export default function PlatformLayout({ children }: { children: React.ReactNode title: 'jump_prompt', content: 'not_allow_standalone_use' }) - + const queryClient = useQueryClient() + const [init, setInit] = useState(false) // init session useEffect(() => { const response = createSealosApp() - ;(async () => { - try { - const newSession = JSON.stringify(await sealosApp.getSession()) - const oldSession = localStorage.getItem('session') - if (newSession && newSession !== oldSession) { - localStorage.setItem('session', newSession) - window.location.reload() + ; (async () => { + try { + + const newSession = JSON.stringify(await sealosApp.getSession()) + const oldSession = sessionStorage.getItem('session') + if(newSession && newSession !== oldSession) { + sessionStorage.setItem('session', newSession) + return window.location.reload() + } + // init user + console.log('devbox: app init success') + const token = (await initUser()) + if (!!token) { + setSessionToSessionStorage(token) + setInit(true) + } + queryClient.clear() + } catch (err) { + console.log('devbox: app is not running in desktop') + if (!process.env.NEXT_PUBLIC_MOCK_USER) { + cleanSession() + openConfirm(() => { + window.open(`https://${env.sealosDomain}`, '_self') + })() + } } - console.log('devbox: app init success') - } catch (err) { - console.log('devbox: app is not running in desktop') - if (!process.env.NEXT_PUBLIC_MOCK_USER) { - localStorage.removeItem('session') - openConfirm(() => { - window.open(`https://${env.sealosDomain}`, '_self') - })() - } - } - })() + })() return response // eslint-disable-next-line react-hooks/exhaustive-deps }, []) useEffect(() => { + if (!init) return setSourcePrice() - setRuntime() + // setRuntime() setEnv() const changeI18n = async (data: any) => { const lastLang = getLangStore() @@ -75,22 +87,22 @@ export default function PlatformLayout({ children }: { children: React.ReactNode } } - ;(async () => { - try { - const lang = await sealosApp.getLanguage() - changeI18n({ - currentLanguage: lang.lng - }) - } catch (error) { - changeI18n({ - currentLanguage: 'zh' - }) - } - })() + ; (async () => { + try { + const lang = await sealosApp.getLanguage() + changeI18n({ + currentLanguage: lang.lng + }) + } catch (error) { + changeI18n({ + currentLanguage: 'zh' + }) + } + })() return sealosApp?.addAppEventListen(EVENT_NAME.CHANGE_I18N, changeI18n) // eslint-disable-next-line react-hooks/exhaustive-deps - }, []) + }, [init]) // add resize event useEffect(() => { @@ -133,13 +145,12 @@ export default function PlatformLayout({ children }: { children: React.ReactNode return ( - - - - - {children} - - + + + + {children} + + ) } diff --git a/frontend/providers/devbox/app/[lang]/(platform)/template/TagCheckbox.tsx b/frontend/providers/devbox/app/[lang]/(platform)/template/TagCheckbox.tsx new file mode 100644 index 00000000000..b9e182097b9 --- /dev/null +++ b/frontend/providers/devbox/app/[lang]/(platform)/template/TagCheckbox.tsx @@ -0,0 +1,49 @@ +import { CheckIcon } from '@chakra-ui/icons'; +import { Box, chakra, CheckboxProps, useCheckbox } from '@chakra-ui/react'; + +export const TagCheckbox = (props: CheckboxProps) => { + const { state, getCheckboxProps, getInputProps, getLabelProps, htmlProps } = + useCheckbox(props); + + return ( + + + + {state.isChecked && ( + + )} + + {props.children} + + ); +}; diff --git a/frontend/providers/devbox/app/[lang]/(platform)/template/TemplateModal/PrivatePanel.tsx b/frontend/providers/devbox/app/[lang]/(platform)/template/TemplateModal/PrivatePanel.tsx new file mode 100644 index 00000000000..f5aacfce782 --- /dev/null +++ b/frontend/providers/devbox/app/[lang]/(platform)/template/TemplateModal/PrivatePanel.tsx @@ -0,0 +1,117 @@ +import { listPrivateTemplateRepository } from "@/api/template"; +import MyIcon from "@/components/Icon"; +import SwitchPage from "@/components/SwitchPage"; +import { Box, Flex, Grid, TabPanel, Text } from "@chakra-ui/react"; +import { useQuery } from "@tanstack/react-query"; +import { useTranslations } from "next-intl"; +import { useEffect, useState } from "react"; +import TemplateCard from "./TemplateCard"; + +export default function PrivatePanel({ + search, }: { + search: string, + }) { + const [pageQueryBody, setPageQueryBody] = useState({ + page: 1, + pageSize: 30, + totalItems: 0, + totalPage: 0, + }) + + // reset query + useEffect(() => { + if (!search) return + setPageQueryBody(prev => ({ + ...prev, + page: 1, + totalItems: 0, + totalPage: 0, + })) + }, [search]) + // reset query + const queryBody = { + page: pageQueryBody.page, + pageSize: pageQueryBody.pageSize, + search, + } + const listPrivateTemplateReposistory = useQuery( + ['template-repository-list', 'template-repository-private', queryBody], + () => { + return listPrivateTemplateRepository(queryBody) + } + ) + + useEffect(() => { + if (listPrivateTemplateReposistory.isFetched && listPrivateTemplateReposistory.isSuccess && listPrivateTemplateReposistory.data) { + const data = listPrivateTemplateReposistory.data.page + setPageQueryBody(prev => ({ + ...prev, + totalItems: data.totalItems || 0, + totalPage: data.totalPage || 0, + page: data.page || 1 + })) + } + }, [listPrivateTemplateReposistory.data, listPrivateTemplateReposistory.isFetched, listPrivateTemplateReposistory.isSuccess]) + + const t = useTranslations() + const privateTempalteReposistoryList = listPrivateTemplateReposistory.data?.templateRepositoryList || [] + return + + + {t('my_templates')} + + + + {privateTempalteReposistoryList.map((tr) => ( + t.tag)} /> + ))} + + {privateTempalteReposistoryList.length === 0 && + + + {t('no_template_repository_versions')} + + } + + + { + setPageQueryBody(page => { + return { + ...page, + page: currentPage, + } + }) + }} + /> + + + +} \ No newline at end of file diff --git a/frontend/providers/devbox/app/[lang]/(platform)/template/TemplateModal/PromptModal.tsx b/frontend/providers/devbox/app/[lang]/(platform)/template/TemplateModal/PromptModal.tsx new file mode 100644 index 00000000000..aa76acb2d50 --- /dev/null +++ b/frontend/providers/devbox/app/[lang]/(platform)/template/TemplateModal/PromptModal.tsx @@ -0,0 +1,47 @@ +import MyIcon from "@/components/Icon" +import { Button, ButtonGroup, Flex, Modal, ModalBody, ModalCloseButton, ModalContent, ModalFooter, ModalHeader, ModalOverlay, ModalProps, Text } from "@chakra-ui/react" +import { useTranslations } from "next-intl" + +const OverviewTemplateVersionModal = ({ onSubmit, version, template, ...props }: Omit & { onSubmit: () => void, version: string, template: string }) => { + const t = useTranslations() + return ( + + + + + + + {t('prompt')} + + + + + {t.rich('overview_template_version_prompt', { + version: {version} as any, + name: {template} as any + })} + + + + + + + + + + ) +} +export default OverviewTemplateVersionModal \ No newline at end of file diff --git a/frontend/providers/devbox/app/[lang]/(platform)/template/TemplateModal/PublicPanel.tsx b/frontend/providers/devbox/app/[lang]/(platform)/template/TemplateModal/PublicPanel.tsx new file mode 100644 index 00000000000..dd92a6e73e2 --- /dev/null +++ b/frontend/providers/devbox/app/[lang]/(platform)/template/TemplateModal/PublicPanel.tsx @@ -0,0 +1,249 @@ +import { listTag, listTemplateRepository } from "@/api/template"; +import SwitchPage from "@/components/SwitchPage"; +import { Tag, TagType } from "@/prisma/generated/client"; +import { createTagSelectorStore } from "@/stores/tagSelector"; +import { getLangStore } from "@/utils/cookie"; +import { Box, Divider, Flex, FlexProps, Grid, Heading, TabPanel, Text } from "@chakra-ui/react"; +import { useQuery } from "@tanstack/react-query"; +import { useTranslations } from "next-intl"; +import { createContext, useContext, useEffect, useState } from "react"; +import { useStore } from "zustand"; +import { TagCheckbox } from "../TagCheckbox"; +import TemplateCard from "./TemplateCard"; +const TagSelectorStoreCtx = createContext | null>(null) +const TagItem = ({ tag, ...props }: { tag: Tag } & FlexProps) => { + const store = useContext(TagSelectorStoreCtx) + if (!store) throw new Error('TagSelectorStoreCtx is null') + const { selectedTagList, setSelectedTag } = useStore(store,) + const lastLang = getLangStore() + return !tag ? null : + { + const selected = e.target.checked + setSelectedTag(tag.uid, selected) + }} + > + + { + tag[lastLang === 'zh' ? 'zhName' : 'enName'] || tag.name + } + + +} +const TagList = ({ tags, title }: { tags: Tag[], title: string }) => { + const t = useTranslations() + return + {title} + { + tags.map((tag, index) => ( + + ))} + + +} +const PublicPanel = ({ + search, }: { + search: string, + }) => { + const [tagSelectorStore] = useState(createTagSelectorStore()) + return + <_PublicPanel + search={search} + /> + +} +function _PublicPanel({ + search, }: { + search: string, + }) { + const tagsQuery = useQuery( + ['template-repository-tags'], + listTag, + { + staleTime: Infinity, + cacheTime: Infinity, + } + ) + let tags = (tagsQuery.data?.tagList || []).sort((a, b) => a.name === 'official' ? -1 : b.name === 'official' ? 1 : 0) + let tagListCollection = tags.reduce((acc, tag) => { + if (!acc[tag.type]) { + acc[tag.type] = []; + } + acc[tag.type].push(tag); + return acc; + }, { + [TagType.OFFICIAL_CONTENT]: [], + [TagType.USE_CASE]: [], + [TagType.PROGRAMMING_LANGUAGE]: [] + } as Record + ); + const store = useContext(TagSelectorStoreCtx) + if (!store) throw new Error('TagSelectorStoreCtx is null') + const state = useStore(store) + + + const [pageQueryBody, setPageQueryBody] = useState({ + page: 1, + pageSize: 30, + totalItems: 0, + totalPage: 0, + }) + + // reset query + useEffect(() => { + if (!search) return + setPageQueryBody(prev => ({ + ...prev, + page: 1, + totalItems: 0, + totalPage: 0, + })) + }, [search]) + // reset query + useEffect(() => { + setPageQueryBody(prev => ({ + ...prev, + page: 1, + totalItems: 0, + totalPage: 0, + })) + }, [state.selectedTagList]) + const queryBody = { + page: pageQueryBody.page, + pageSize: pageQueryBody.pageSize, + search, + tags: state.getSelectedTagList(), + } + const listTemplateReposistory = useQuery( + ['template-repository-list', 'template-repository-public', queryBody], + () => { + return listTemplateRepository({ + page: queryBody.page, + pageSize: queryBody.pageSize, + }, queryBody.tags, queryBody.search) + }, + ) + + useEffect(() => { + if (listTemplateReposistory.isSuccess && listTemplateReposistory.data) { + const data = listTemplateReposistory.data.page + setPageQueryBody(prev => ({ + ...prev, + totalItems: data.totalItems || 0, + totalPage: data.totalPage || 0, + page: data.page || 1 + })) + } + }, [listTemplateReposistory.data]) // 添加依赖项 + + const tempalteReposistoryList = listTemplateReposistory.data?.templateRepositoryList || [] + const t = useTranslations() + return + + {/* Left Sidebar */} + + + {t('tags')} + + + + + {/* */} + + + + + + + + + + + + {/* Right Content */} + + + {t('all_templates')} + + + + {tempalteReposistoryList.map((tr) => { + return t.tag)} + isPublic + /> + })} + + + + { + setPageQueryBody(page => { + return { + ...page, + page: currentPage, + } + }) + }} + /> + + + + ; +} +export default PublicPanel \ No newline at end of file diff --git a/frontend/providers/devbox/app/[lang]/(platform)/template/TemplateModal/TemplateCard.tsx b/frontend/providers/devbox/app/[lang]/(platform)/template/TemplateModal/TemplateCard.tsx new file mode 100644 index 00000000000..176e505ba03 --- /dev/null +++ b/frontend/providers/devbox/app/[lang]/(platform)/template/TemplateModal/TemplateCard.tsx @@ -0,0 +1,263 @@ +import MyIcon from '@/components/Icon'; +import { useRouter } from '@/i18n'; +import { type Tag as TTag } from '@/prisma/generated/client'; +import { useDevboxStore } from '@/stores/devbox'; +import { useTemplateStore } from '@/stores/template'; +import { Box, BoxProps, Button, Flex, Img, MenuButton, Tag, Text, useDisclosure } from '@chakra-ui/react'; +import { MyTooltip, SealosMenu } from '@sealos/ui'; +import { useLocale, useTranslations } from 'next-intl'; +import DeleteTemplateReposistoryModal from '../updateTemplate/DeleteTemplateReposistoryModal'; +import EditTemplateModal from '../updateTemplate/EditTemplateModal'; +import EditTemplateRepositoryModal from '../updateTemplate/EditTemplateReposistoryModal'; +const TemplateCard = ({ isPublic, iconId, templateRepositoryName, templateRepositoryDescription + , templateRepositoryUid, + isDisabled = false, + inPublicStore = true, + tags, + ...props +}: { + isPublic?: boolean + iconId: string, + isDisabled?: boolean, + inPublicStore?: boolean, + templateRepositoryName: string, + templateRepositoryDescription: string | null + templateRepositoryUid: string, + tags: TTag[] +} & BoxProps) => { + const t = useTranslations() + const { closeTemplateModal, config, updateTemplateModalConfig } = useTemplateStore() + const editTemplateHandle = useDisclosure() + const editTemplateRepositoryHandle = useDisclosure() + const deleteTemplateHandle = useDisclosure() + const { setStartedTemplate } = useDevboxStore() + const router = useRouter() + const description = templateRepositoryDescription ? templateRepositoryDescription : t('no_description') + const lastLang = useLocale() + return ( + <> + + + + + {/* Python Logo */} + + + {/* Title and Description */} + + + + {templateRepositoryName} + + {inPublicStore ? + (tags.findIndex(tag => tag.name === 'official') !== -1 ? + + + + : <>) + : + (isPublic ? + + {t('public')} + : + + {t('private')} + ) + } + + + + + {description} + + + + + + + + {/* Buttons */} + + + {!inPublicStore && + + + + } + menuList={[ + { + child: ( + <> + + {t('edit')} + + ), + onClick: editTemplateRepositoryHandle.onOpen + }, + { + child: ( + <> + + {t('version_manage')} + + ), + onClick: editTemplateHandle.onOpen + }, + { + child: ( + <> + + {t('delete')} + + ), + onClick: deleteTemplateHandle.onOpen + }, + ]} + /> + } + + + + {/* Tags */} + + {tags.filter(tag => tag.name !== 'official').map(tag => + {tag[lastLang === 'zh' ? 'zhName' : 'enName'] || tag.name} + )} + + + + + + + ); +}; + +export default TemplateCard; \ No newline at end of file diff --git a/frontend/providers/devbox/app/[lang]/(platform)/template/TemplateModal/index.tsx b/frontend/providers/devbox/app/[lang]/(platform)/template/TemplateModal/index.tsx new file mode 100644 index 00000000000..f015206102e --- /dev/null +++ b/frontend/providers/devbox/app/[lang]/(platform)/template/TemplateModal/index.tsx @@ -0,0 +1,165 @@ +"use client" + +import MyIcon from '@/components/Icon' +import { TemplateState } from '@/constants/template' +import { usePathname } from '@/i18n' +import { useTemplateStore } from '@/stores/template' +import { + calc, + Input, + InputGroup, + InputLeftElement, + Modal, + ModalBody, + ModalCloseButton, + ModalContent, + ModalHeader, + ModalOverlay, + Tab, + TabList, + TabPanels, + Tabs, + Text +} from '@chakra-ui/react' +import { SearchIcon } from '@sealos/ui' +import { debounce } from 'lodash' +import { useTranslations } from 'next-intl' +import { useCallback, useState } from 'react' +import PrivatePanel from './PrivatePanel' +import PublicPanel from './PublicPanel' + + +const TemplateModal = () => { + const t = useTranslations() + const { isOpen, config, closeTemplateModal, openTemplateModal, updateTemplateModalConfig } = useTemplateStore() + const [search, setsearch] = useState('') + const updateSearchVal = useCallback( + debounce((val: string) => { + setsearch(val) + }, 500), + [] + ) + const lastRoute = usePathname() + return ( + { + closeTemplateModal() + updateTemplateModalConfig({ + templateState: TemplateState.publicTemplate, + lastRoute + }) + }} lockFocusAcrossFrames={false}> + + + {t('devbox_template')} + + + {/* */} + { + if (idx === 0) openTemplateModal({ + templateState: TemplateState.publicTemplate, + lastRoute + }) + else openTemplateModal({ + templateState: TemplateState.privateTemplate, + lastRoute + }) + }}> + {/* TabList must be direct child of Tabs */} + + + + + {t('all_templates')} + + + + + + {t('my_templates')} + + + + + + + { + updateSearchVal(e.target.value) + }} + /> + + + + {/* TabPanels must be direct child of Tabs */} + + + + + + {/* */} + + + + ) +} + +export default TemplateModal \ No newline at end of file diff --git a/frontend/providers/devbox/app/[lang]/(platform)/template/updateTemplate/CreateTemplateModal.tsx b/frontend/providers/devbox/app/[lang]/(platform)/template/updateTemplate/CreateTemplateModal.tsx new file mode 100644 index 00000000000..9ed10002b9f --- /dev/null +++ b/frontend/providers/devbox/app/[lang]/(platform)/template/updateTemplate/CreateTemplateModal.tsx @@ -0,0 +1,236 @@ +import { createTemplateReposistory } from '@/api/template'; +import MyFormLabel from '@/components/MyFormControl'; +import { TemplateState } from '@/constants/template'; +import { usePathname } from '@/i18n'; +import { useTemplateStore } from '@/stores/template'; +import { templateNameSchema, versionSchema } from '@/utils/vaildate'; +import { + Button, + ButtonGroup, + Flex, + Input, + Modal, + ModalBody, + ModalCloseButton, + ModalContent, + ModalFooter, + ModalHeader, + ModalOverlay, + Text, + VStack +} from '@chakra-ui/react'; +import { useMessage } from '@sealos/ui'; +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { useTranslations } from 'next-intl'; +import { FC } from 'react'; +import { Controller, FormProvider, SubmitHandler, useForm } from 'react-hook-form'; +import { z } from 'zod'; +import TemplateRepositoryDescriptionField from './components/TemplateRepositoryDescriptionField'; +import TemplateRepositoryIsPublicField from './components/TemplateRepositoryIsPublicField'; +import TemplateRepositoryNameField from './components/TemplateRepositoryNameField'; +import TemplateRepositoryTagField from './components/TemplateRepositoryTagField'; +const tagSchema = z.object({ + value: z.string(), +}); + +// 定义表单数据类型和验证规则 + +interface CreateTemplateModalProps { + isOpen: boolean; + onClose: () => void; + devboxReleaseName: string; + // onSubmit?: (data: FormData) => void; +} +const CreateTemplateModal: FC = ({ + isOpen, + onClose, + // onSubmit + devboxReleaseName +}) => { + const t = useTranslations() + const formSchema = z.object({ + name: z.string().min(1, t('input_template_name_placeholder')).pipe(templateNameSchema), + version: z.string().min(1, t('input_template_version_placeholder')).pipe(versionSchema), + isPublic: z.boolean().default(false), + agreeTerms: z.boolean().refine((val) => val === true, t('privacy_and_security_agreement_tips')), + tags: z.array(tagSchema).min(1, t('select_at_least_1_tag')).max(3, t('select_lest_than_3_tags')), + description: z.string(), + }); + + type FormData = z.infer; + type Tag = z.infer; + const methods = useForm({ + defaultValues: { + name: '', + version: '', + isPublic: false, + agreeTerms: false, + tags: [], + description: '', + }, + mode: 'onSubmit' + }); + const { + control, + handleSubmit, + formState: { errors, isSubmitting }, + reset, + } = methods + const { openTemplateModal, config } = useTemplateStore() + const queryClient = useQueryClient() + const mutation = useMutation({ + mutationFn: createTemplateReposistory + // return await createTemplate(data) + }) + const { message: toast } = useMessage() + const lastRoute = usePathname() + const onSubmitHandler: SubmitHandler = async (_data) => { + try { + const result = formSchema.safeParse(_data) + if (!result.success) { + // const title = result.error.errors[0] + const error = result.error.errors[0] + if(error.path[0] === 'name' && error.code === 'invalid_string') { + toast({ + title: t('invalide_template_name'), + status: 'error', + }); + return; + } + if(error.path[0] === 'version' && error.code === 'invalid_string') { + toast({ + title: t('invalide_template_version'), + status: 'error', + }); + return; + } + const title = error.message + toast({ + title, + status: 'error', + }); + return; + } + const data = result.data + await mutation.mutateAsync({ + templateRepositoryName: data.name, + version: data.version, + isPublic: data.isPublic, + description: data.description, + tagUidList: data.tags.map((tag) => tag.value), + devboxReleaseName + }); + queryClient.invalidateQueries(['template-repository-list']) + queryClient.invalidateQueries(['template-repository-detail']) + reset(); + onClose(); + openTemplateModal({ + templateState: TemplateState.privateTemplate, + lastRoute + }) + toast({ + title: t('create_template_success'), + status: 'success', + }); + } catch (error) { + + if(error == '409:templateRepository name already exists') { + return toast({ + title: t('template_repository_name_already_exists'), + status: 'error', + }); + } + toast({ + title: error as string, + status: 'error', + }); + } + }; + + return ( + reset()} + > + + + + + + {t('create_template')} + + + + + {/* 名称 */} + + + {/* 版本号 */} + + + {t('version')} + ( + + )} + /> + + + + {/* 公开 */} + {/* */} + + + {/* 标签 */} + + + + {/* 简介 */} + + + + + + {/* 按钮组 */} + + + + + + + + + + ); +}; + +export default CreateTemplateModal \ No newline at end of file diff --git a/frontend/providers/devbox/app/[lang]/(platform)/template/updateTemplate/DeleteTemplateReposistoryModal.tsx b/frontend/providers/devbox/app/[lang]/(platform)/template/updateTemplate/DeleteTemplateReposistoryModal.tsx new file mode 100644 index 00000000000..ff8fc9c6ab4 --- /dev/null +++ b/frontend/providers/devbox/app/[lang]/(platform)/template/updateTemplate/DeleteTemplateReposistoryModal.tsx @@ -0,0 +1,76 @@ +import { deleteTemplateRepository } from '@/api/template' +import MyIcon from '@/components/Icon' +import { + Button, + ButtonGroup, + Flex, + Modal, + ModalBody, + ModalCloseButton, + ModalContent, + ModalFooter, + ModalHeader, + ModalOverlay, + ModalProps, + Text +} from '@chakra-ui/react' +import { useMutation, useQueryClient } from '@tanstack/react-query' +import { useTranslations } from 'next-intl' + +const DeleteTemplateReposistoryModal = ({ uid, templateRepositoryName, ...props }: Omit & { + templateRepositoryName: string + uid: string +}) => { + const t = useTranslations() + const mutation = useMutation({ + mutationFn: (uid: string) => { + return deleteTemplateRepository(uid) + }, + onSuccess: () => { + queryClient.invalidateQueries(['template-repository-list']) + queryClient.invalidateQueries(['template-repository-detail']) + } + }) + const queryClient = useQueryClient() + return ( + + + + + + + {t('prompt')} + + + + + {t.rich('delete_template_prompt', { + name: {templateRepositoryName} as any + })} + + + + + + + + + + ) +} + +export default DeleteTemplateReposistoryModal \ No newline at end of file diff --git a/frontend/providers/devbox/app/[lang]/(platform)/template/updateTemplate/EditTemplateModal.tsx b/frontend/providers/devbox/app/[lang]/(platform)/template/updateTemplate/EditTemplateModal.tsx new file mode 100644 index 00000000000..7638112cb03 --- /dev/null +++ b/frontend/providers/devbox/app/[lang]/(platform)/template/updateTemplate/EditTemplateModal.tsx @@ -0,0 +1,192 @@ +import { listTemplate } from '@/api/template'; +import MyIcon from '@/components/Icon'; +import MyTable from '@/components/MyTable'; +import { + Box, + Flex, + IconButton, + Modal, + ModalBody, + ModalCloseButton, + ModalContent, + ModalHeader, + ModalOverlay, + Text, + useDisclosure +} from '@chakra-ui/react'; +import { useQuery } from '@tanstack/react-query'; +import dayjs from 'dayjs'; +import { useTranslations } from 'next-intl'; +import { FC, useState } from 'react'; +import { z } from 'zod'; +import DeleteTemplateVersionModal from '../updateTemplateVersion/DeleteTemplateVersionModal'; +const tagSchema = z.object({ + value: z.string().min(1), +}); +const versionSchema = z.object({ + name: z.string(), + uid: z.string(), +}); +type VersionType = z.infer; +interface CreateTemplateModalProps { + isOpen: boolean; + onClose: () => void; + onSubmit?: (data: FormData) => void; + uid: string; + templateRepositoryName: string; +} + +const EditTemplateModal: FC = ({ + isOpen, + onClose, + uid, + templateRepositoryName +}) => { + const t = useTranslations() + const DeleteTemplateVersionHandle = useDisclosure() + const [deletedTemplateVersion, setDeletedTemplateVersion] = useState() + const templateRepositoryQuery = useQuery( + ['templateList', uid], + () => listTemplate(uid), + { + enabled: isOpen + } + ) + const columns: { + title: string + dataIndex?: keyof { + uid: string; + name: string; + config: string; + image: string; + createAt: Date; + updateAt: Date; + } + minWidth?: string + key: string + render?: (item: { + uid: string; + name: string; + config: string; + image: string; + createAt: Date; + updateAt: Date; + }) => JSX.Element + }[] = [ + { + title: t('version'), + key: 'name', + render: (item) => { + return ( + + {item.name} + + ) + } + }, + { + title: t('creation_time'), + dataIndex: 'createAt', + key: 'createAt', + render: (item) => { + return {dayjs().format('YYYY-MM-DD mm:ss')} + } + }, { + title: t('update_time'), + dataIndex: 'createAt', + key: 'createAt', + render: (item) => { + return {dayjs().format('YYYY-MM-DD mm:ss')} + } + }, + { + title: t('control'), + key: 'control', + minWidth: 'unset', + render: (item) => ( + // + } + minW={'unset'} + onClick={() => { + setDeletedTemplateVersion({ + name: item.name, + uid: item.uid, + }) + DeleteTemplateVersionHandle.onOpen() + }} + /> + // + ) + } + ] + // const mock = [ + // { + // "uid": "741bc275-107a-43a7-ac01-2d26a0f64297", + // "name": "v1", + // "config": "{\"appPorts\":[{\"name\":\"devbox-app-port\",\"port\":8080,\"protocol\":\"TCP\",\"targetPort\":0}],\"ports\":[{\"containerPort\":22,\"name\":\"devbox-ssh-port\",\"protocol\":\"TCP\"}],\"releaseArgs\":[\"/home/devbox/project/entrypoint.sh\"],\"releaseCommand\":[\"/bin/bash\",\"-c\"],\"user\":\"devbox\",\"workingDir\":\"/home/devbox/project\"}", + // "image": "sealos.hub:5000/vfwsepe/devbox4:v1", + // "createdAt": "2024-12-13T08:29:26.171Z", + // "updatedAt": "2024-12-13T08:29:26.171Z" + // }, + // ] + const templateList = + templateRepositoryQuery.data?.templateList || [] + return (<> + + + + + {t('version_manage')} + + + + + + + + {templateList.length === 0 && + + + {t('no_template_versions')} + + } + + + + + + {!!deletedTemplateVersion && } + + ); +}; + +export default EditTemplateModal \ No newline at end of file diff --git a/frontend/providers/devbox/app/[lang]/(platform)/template/updateTemplate/EditTemplateReposistoryModal.tsx b/frontend/providers/devbox/app/[lang]/(platform)/template/updateTemplate/EditTemplateReposistoryModal.tsx new file mode 100644 index 00000000000..dcef3b9da3e --- /dev/null +++ b/frontend/providers/devbox/app/[lang]/(platform)/template/updateTemplate/EditTemplateReposistoryModal.tsx @@ -0,0 +1,210 @@ +import { getTemplateRepository, updateTemplateReposistory } from '@/api/template'; +import { TemplateVersionState } from '@/constants/template'; +import { + Button, + ButtonGroup, + Modal, + ModalBody, + ModalCloseButton, + ModalContent, + ModalFooter, + ModalHeader, + ModalOverlay, + Text, + useDisclosure, + VStack +} from '@chakra-ui/react'; +import { useMessage } from '@sealos/ui'; +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { useTranslations } from 'next-intl'; +import { FC, useEffect, useState } from 'react'; +import { FormProvider, useForm } from 'react-hook-form'; +import { z } from 'zod'; +import TemplateRepositoryDescriptionField from './components/TemplateRepositoryDescriptionField'; +import TemplateRepositoryIsPublicField from './components/TemplateRepositoryIsPublicField'; +import TemplateRepositoryNameField from './components/TemplateRepositoryNameField'; +import TemplateRepositoryTagField from './components/TemplateRepositoryTagField'; +const tagSchema = z.object({ + value: z.string().min(1), +}); +const versionSchema = z.object({ + name: z.string(), + uid: z.string(), + state: z.nativeEnum(TemplateVersionState) +}); +type VersionType = z.infer; +interface CreateTemplateModalProps { + isOpen: boolean; + onClose: () => void; + onSubmit?: (data: FormData) => void; + uid: string; +} + +const EditTemplateRepositoryModal: FC = ({ + isOpen, + onClose, + uid +}) => { + const t = useTranslations() + const formSchema = z.object({ + name: z.string().min(1, t('input_template_name_placeholder')), + isPublic: z.boolean().default(false), + agreeTerms: z.boolean().refine((val) => val === true, t('privacy_and_security_agreement_tips')), + tags: z.array(tagSchema).min(1, t('select_at_least_1_tag')).max(3, t('select_lest_than_3_tags')), + description: z.string(), + }); + type FormData = z.infer; + const methods = useForm({ + defaultValues: { + name: '', + isPublic: false, + agreeTerms: false, + tags: [], + description: '', + }, + }); + const { + control, + handleSubmit, + formState: { errors, isSubmitting }, + reset, + setValue, + } = methods + + const DeleteTemplateVersionHandle = useDisclosure() + const [deletedTemplateVersion, setDeletedTemplateVersion] = useState() + const templateRepositoryQuery = useQuery( + ['template-repository-detail', uid], + () => getTemplateRepository(uid), + { + enabled: isOpen + } + ) + const updateMutation = useMutation( + updateTemplateReposistory, + { + onSuccess() { + queryClient.invalidateQueries(['template-repository-list']) + queryClient.invalidateQueries(['template-repository-detail']) + } + } + ) + const templateRepository = templateRepositoryQuery.data?.templateRepository + const [publicIsDisabled, setPublicIsDisabled] = useState(false) + useEffect(() => { + if (isOpen && templateRepository && templateRepositoryQuery.isSuccess) { + setValue('tags', templateRepository.templateRepositoryTags.map(({ tag }) => ({ + value: tag.uid, + }))) + + setValue('name', templateRepository.name) + setValue('description', templateRepository.description || '') + setValue('isPublic', templateRepository.isPublic) + if (templateRepository.isPublic) { + setPublicIsDisabled(true) + setValue('agreeTerms', true) + } + } + }, [templateRepository, isOpen]) + const { message: toast } = useMessage() + const queryClient = useQueryClient() + const onSubmitHandler = async (_data: FormData) => { + try { + const result = formSchema.safeParse(_data) + if (!result.success) { + const error = result.error.errors[0] + toast({ + title: error.message, + status: 'error', + }); + return; + } + const data = result.data + await updateMutation.mutateAsync({ + uid, + templateRepositoryName: data.name, + isPublic: data.isPublic, + description: data.description, + tagUidList: data.tags.map(({ value }) => value) + }); + queryClient.invalidateQueries(['template-repository-list']) + reset(); + onClose(); + toast({ + title: t('template_ssaved_successfully'), + status: 'success', + }); + } catch (error) { + toast({ + title: error as string, + status: 'error', + }); + } + }; + return (<> + reset()} + > + + + +
+ + {t('edit_template')} + + + + + + {/* 名称 */} + + + {/* 公开 */} + + + {/* 标签 */} + + + {/* 简介 */} + + + + + + {/* 按钮组 */} + + + + + +
+
+ +
+
+ + ); +}; + +export default EditTemplateRepositoryModal \ No newline at end of file diff --git a/frontend/providers/devbox/app/[lang]/(platform)/template/updateTemplate/SelectActionModal/TemplateDropdown.tsx b/frontend/providers/devbox/app/[lang]/(platform)/template/updateTemplate/SelectActionModal/TemplateDropdown.tsx new file mode 100644 index 00000000000..34c6518c38d --- /dev/null +++ b/frontend/providers/devbox/app/[lang]/(platform)/template/updateTemplate/SelectActionModal/TemplateDropdown.tsx @@ -0,0 +1,151 @@ +import MyIcon from "@/components/Icon"; +import { ChevronDownIcon } from "@chakra-ui/icons"; +import { Box, Button, ButtonProps, HStack, Img, Popover, PopoverBody, PopoverContent, PopoverTrigger, Text, useDisclosure, VStack } from "@chakra-ui/react"; + +const TemplateButton = ({ isActive = false, icon, title, description, onClick, isInMenu = false, ...props }: ButtonProps & { + icon: React.ReactNode; + title: string; + isActive?: boolean; + isInMenu?: boolean + description: string +}) => { + return ( + + ); +}; +type TRepositoryItem = { + iconId: string | null, + name: string, + description: string | null, + uid: string +} +export default function TemplateDropdown({ + templateRepositoryList, + selectedTemplateRepoUid, + setSelectedTemplateRepoUid +}: { + templateRepositoryList: TRepositoryItem[], + selectedTemplateRepoUid: string | null, + setSelectedTemplateRepoUid: (uid: string) => void +}) { + const selectedTemplateRepository = templateRepositoryList.find(t => t.uid === selectedTemplateRepoUid); + const { + isOpen, onOpen, onClose } = useDisclosure() + return ( + isOpen && onClose()} onOpen={() => !isOpen && onOpen()}> + + + } + title={selectedTemplateRepository?.name || ''} + description={selectedTemplateRepository?.description || ''} + /> + + + {/* */} + + + + {templateRepositoryList.map(({ uid, iconId, description, name }) => ( + } + isInMenu + title={name} + description={description || ''} + onClick={() => setSelectedTemplateRepoUid(uid)} + /> + ))} + + + + + + ); +}; \ No newline at end of file diff --git a/frontend/providers/devbox/app/[lang]/(platform)/template/updateTemplate/SelectActionModal/index.tsx b/frontend/providers/devbox/app/[lang]/(platform)/template/updateTemplate/SelectActionModal/index.tsx new file mode 100644 index 00000000000..de780917b1b --- /dev/null +++ b/frontend/providers/devbox/app/[lang]/(platform)/template/updateTemplate/SelectActionModal/index.tsx @@ -0,0 +1,83 @@ +import { + Button, + Modal, + ModalBody, + ModalCloseButton, + ModalContent, + ModalFooter, + ModalHeader, + ModalOverlay, + Text +} from '@chakra-ui/react'; +import { useTranslations } from 'next-intl'; + +import MyIcon from '@/components/Icon'; +import { useMessage } from '@sealos/ui'; +import { useState } from "react"; +import TemplateDropdown from './TemplateDropdown'; + + +const SelectTemplateModal = ({ + isOpen, + onClose, + onOpenCreate, + onOpenUdate, + templateRepositoryList +}: { + isOpen: boolean; + onClose: () => void; + templateRepositoryList: { iconId: string | null, name: string, description: null | string, uid: string }[] + onSubmit?: (data: FormData) => void; + onOpenCreate: () => void; + onOpenUdate: (templateRepoUid: string) => void; +}) => { + const { message } = useMessage() + const t = useTranslations() + const [selectedTemplateRepoUid, setSelectedTemplateRepoUid] = useState(templateRepositoryList?.[0]?.uid); + return ( + + + + + {t('create_or_update_template')} + + + + {t("select_template_tips")} + + + + + + + + + ); +}; + +export default SelectTemplateModal \ No newline at end of file diff --git a/frontend/providers/devbox/app/[lang]/(platform)/template/updateTemplate/UpdateTemplateRepositoryModal.tsx b/frontend/providers/devbox/app/[lang]/(platform)/template/updateTemplate/UpdateTemplateRepositoryModal.tsx new file mode 100644 index 00000000000..5bb60056ea6 --- /dev/null +++ b/frontend/providers/devbox/app/[lang]/(platform)/template/updateTemplate/UpdateTemplateRepositoryModal.tsx @@ -0,0 +1,347 @@ +import { updateTemplate } from '@/api/template'; +import MyIcon from '@/components/Icon'; +import MyFormLabel from '@/components/MyFormControl'; +import { versionSchema } from '@/utils/vaildate'; +import { + Box, + Button, + ButtonGroup, + Flex, + HStack, + Input, + Modal, + ModalBody, + ModalCloseButton, + ModalContent, + ModalFooter, + ModalHeader, + ModalOverlay, + Popover, + PopoverBody, + PopoverContent, + PopoverTrigger, + Text, + useDisclosure, + VStack +} from '@chakra-ui/react'; +import { AddIcon, useMessage } from '@sealos/ui'; +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { useTranslations } from 'next-intl'; +import { FC, useEffect, useState } from 'react'; +import { FormProvider, useForm, useFormContext } from 'react-hook-form'; +import { z } from 'zod'; +import OverviewTemplateVersionModal from '../updateTemplateVersion/OverviewTemplateVersionModal'; +import TemplateRepositoryDescriptionField from './components/TemplateRepositoryDescriptionField'; +import TemplateRepositoryNameField from './components/TemplateRepositoryNameField'; +import TemplateRepositoryTagField from './components/TemplateRepositoryTagField'; + + +interface CreateTemplateModalProps { + isOpen: boolean; + onClose: () => void; + devboxReleaseName: string + templateRepository: { + uid: string; + name: string; + description: string | null; + iconId: string | null; + isPublic: boolean; + templates: { + uid: string; + name: string; + }[]; + templateRepositoryTags: { + tag: { + uid: string; + name: string; + }; + }[]; + }; +} + + +const VersionSelect = ({ templateList }: { templateList: { uid: string, name: string }[] }) => { + const { + watch, + setValue, + } = useFormContext(); + const [inputValue, setInputValue] = useState("") + + const handleVersionSelect = (version: string) => { + setInputValue(version) + setValue('version', inputValue) + handler.onClose() + } + const handler = useDisclosure() + + const handleCreateVersion = () => { + // 处理创建新版本的逻辑 + setValue('version', inputValue) + handler.onClose() + } + const t = useTranslations() + return (<> + + + + {watch('version')} + + + + + + { + setInputValue(e.target.value) + }} + // border="1px solid #219BF4" + // boxShadow="0px 0px 0px 2.4px rgba(51, 112, 255, 0.15)" + borderRadius="4px" + fontSize="12px" + placeholder={t('search_or_add_version')} + _focus={{ + border: "1px solid #219BF4", + boxShadow: "0px 0px 0px 2.4px rgba(51, 112, 255, 0.15)", + }} + /> + + {/* 已有版本列表 */} + {templateList + .filter(v => v.name.toLowerCase().includes(inputValue.toLowerCase())) + .map((v) => ( + handleVersionSelect(v.name)} + > + {v.name} + + ))} + + {/* 创建新版本选项 */} + {inputValue && !templateList.find(v => v.name === inputValue) && ( + + + + {t('create_template_version', { + version: inputValue, + })} + + + )} + + + + + + ) +} +const UpdateTemplateRepositoryModal: FC = ({ + isOpen, + onClose, + templateRepository, + devboxReleaseName +}) => { + const t = useTranslations() + const tagSchema = z.object({ + value: z.string(), + }); + const formSchema = z.object({ + name: z.string().min(1, t('input_template_name_placeholder')), + version: z.string().min(1, t('input_template_version_placeholder')).pipe(versionSchema), + tags: z.array(tagSchema).min(1, t('select_at_least_1_tag')).max(3, t('select_lest_than_3_tags')), + description: z.string(), + }); + const queryClient = useQueryClient() + type FormData = z.infer; + const mutation = useMutation(updateTemplate, { + onSuccess() { + queryClient.invalidateQueries(['template-repository-list']) + queryClient.invalidateQueries(['template-repository-detail']) + } + }) + const methods = useForm({ + defaultValues: { + name: '', + version: '', + // agreeTerms: false, + tags: [], + description: '', + }, + }); + const { + handleSubmit, + formState: { errors, isSubmitting }, + reset, + setValue, + watch + } = methods + useEffect(() => { + if (templateRepository && isOpen) { + setValue('tags', templateRepository.templateRepositoryTags.map(({ tag }) => ({ + value: tag.uid, + }))) + setValue('version', templateRepository.templates[0]?.name || '') + setValue('name', templateRepository.name) + setValue('description', templateRepository.description || '') + } + }, [templateRepository, isOpen]) + const { message: toast } = useMessage() + const overviewHandler = useDisclosure() + const submit = async (_data: FormData) => { + try { + const result = formSchema.safeParse(_data) + if (!result.success) { + const error = result.error.errors[0] + if(error.path[0] === 'version' && error.code === 'invalid_string') { + toast({ + title: t('invalide_template_version'), + status: 'error', + }); + return; + } + toast({ + title: error.message, + status: 'error', + }); + return; + } + const data = result.data + await mutation.mutateAsync({ + templateRepositoryUid: templateRepository?.uid || '', + version: data.version, + devboxReleaseName, + description: data.description, + tagUidList: data.tags.map(({ value }) => value), + }) + + queryClient.invalidateQueries(['template-repository-list']) + reset(); + onClose(); + toast({ + title: t('update_template_success'), + status: 'success', + }); + } catch (error) { + toast({ + title: error as string, + status: 'error', + }); + } + }; + const onSubmitHandler = (data: FormData) => { + if (templateRepository.templates.findIndex(d => data.version === d.name) > -1) { + overviewHandler.onOpen() + return + } + return submit(data) + }; + + return (<> + reset()} + > + + + +
+ + {t('update_template')} + + + + + {/* 名称 */} + + + {/* 版本号 */} + + {t('version')} + + + + + + {/* 标签 */} + + + {/* 简介 */} + + + + + + + {/* 按钮组 */} + + + + + +
+
+ +
+
+ { + submit(methods.getValues()) + }} version={watch('version')} template={templateRepository.name} /> + + ); +}; + +export default UpdateTemplateRepositoryModal \ No newline at end of file diff --git a/frontend/providers/devbox/app/[lang]/(platform)/template/updateTemplate/components/TemplateRepositoryDescriptionField.tsx b/frontend/providers/devbox/app/[lang]/(platform)/template/updateTemplate/components/TemplateRepositoryDescriptionField.tsx new file mode 100644 index 00000000000..a9f978b66af --- /dev/null +++ b/frontend/providers/devbox/app/[lang]/(platform)/template/updateTemplate/components/TemplateRepositoryDescriptionField.tsx @@ -0,0 +1,28 @@ +import MyFormLabel from "@/components/MyFormControl"; +import { Flex, Textarea } from "@chakra-ui/react"; +import { useTranslations } from "next-intl"; +import { Controller, useFormContext } from "react-hook-form"; +export default function TemplateRepositoryDescriptionField() { + const { control } = useFormContext<{ description: string }>(); + const t = useTranslations() + return + {t('template_description')} + ( +