diff --git a/frontend/providers/devbox/api/devbox.ts b/frontend/providers/devbox/api/devbox.ts index 0e3296a42c4e..6cec0b4fc2ea 100644 --- a/frontend/providers/devbox/api/devbox.ts +++ b/frontend/providers/devbox/api/devbox.ts @@ -1,4 +1,4 @@ -import { V1Pod } from '@kubernetes/client-node' +import { V1Deployment, V1Pod, V1StatefulSet } from '@kubernetes/client-node' import { DevboxEditType, @@ -8,6 +8,7 @@ import { runtimeNamespaceMapType } from '@/types/devbox' import { + adaptAppListItem, adaptDevboxDetail, adaptDevboxListItem, adaptDevboxVersionListItem, @@ -82,3 +83,8 @@ export const getDevboxMonitorData = (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/app/[lang]/(platform)/devbox/detail/[name]/components/MainBody.tsx b/frontend/providers/devbox/app/[lang]/(platform)/devbox/detail/[name]/components/MainBody.tsx index 8cf38bb7f813..09d44dcfa37d 100644 --- a/frontend/providers/devbox/app/[lang]/(platform)/devbox/detail/[name]/components/MainBody.tsx +++ b/frontend/providers/devbox/app/[lang]/(platform)/devbox/detail/[name]/components/MainBody.tsx @@ -27,7 +27,20 @@ const MainBody = () => { dataIndex?: keyof NetworkType key: string render?: (item: NetworkType) => JSX.Element + width?: string }[] = [ + { + title: t('port'), + key: 'port', + render: (item: NetworkType) => { + return ( + + {item.port} + + ) + }, + width: '0.5fr' + }, { title: t('internal_address'), key: 'internalAddress', @@ -47,7 +60,6 @@ const MainBody = () => { _hover={{ textDecoration: 'underline' }} - ml={4} color={'grayModern.600'} onClick={() => copyData( diff --git a/frontend/providers/devbox/app/[lang]/(platform)/devbox/detail/[name]/components/Version.tsx b/frontend/providers/devbox/app/[lang]/(platform)/devbox/detail/[name]/components/Version.tsx index 20b250b4f7a4..de64902cb8be 100644 --- a/frontend/providers/devbox/app/[lang]/(platform)/devbox/detail/[name]/components/Version.tsx +++ b/frontend/providers/devbox/app/[lang]/(platform)/devbox/detail/[name]/components/Version.tsx @@ -12,14 +12,16 @@ import ReleaseModal from '@/components/modals/releaseModal' import EditVersionDesModal from '@/components/modals/EditVersionDesModal' import { DevboxVersionListItemType } from '@/types/devbox' -import { DevboxReleaseStatusEnum } from '@/constants/devbox' -import { delDevboxVersionByName, getSSHRuntimeInfo } from '@/api/devbox' +import { DevboxReleaseStatusEnum, devboxIdKey } from '@/constants/devbox' +import { delDevboxVersionByName, getAppsByDevboxId, getSSHRuntimeInfo } from '@/api/devbox' import { useConfirm } from '@/hooks/useConfirm' import { useLoading } from '@/hooks/useLoading' import { useEnvStore } from '@/stores/env' import { useDevboxStore } from '@/stores/devbox' +import AppSelectModal from '@/components/modals/AppSelectModal' +import { AppListItemType } from '@/types/app' const Version = () => { const t = useTranslations() @@ -32,6 +34,9 @@ const Version = () => { const [initialized, setInitialized] = useState(false) const [onOpenRelease, setOnOpenRelease] = useState(false) + const [onOpenSelectApp, setOnOpenSelectApp] = useState(false) + const [apps, setApps] = useState([]) + const [deployData, setDeployData] = useState(null) const [currentVersion, setCurrentVersion] = useState(null) const { openConfirm, ConfirmChild } = useConfirm({ @@ -55,6 +60,8 @@ const Version = () => { const handleDeploy = useCallback( async (version: DevboxVersionListItemType) => { + const devboxId = devbox.id + const { releaseCommand, releaseArgs } = await getSSHRuntimeInfo(devbox.runtimeVersion) const { cpu, memory, networks, name } = devbox const newNetworks = networks.map((network) => { @@ -65,12 +72,13 @@ const Version = () => { domain: env.ingressDomain } }) + const imageName = `${env.registryAddr}/${env.namespace}/${devbox.name}:${version.tag}` const transformData = { appName: `${name}-release`, cpu: cpu, memory: memory, - imageName: `${env.registryAddr}/${env.namespace}/${devbox.name}:${version.tag}`, + imageName: imageName, networks: newNetworks.length > 0 ? newNetworks @@ -83,20 +91,33 @@ const Version = () => { } ], runCMD: releaseCommand, - cmdParam: releaseArgs + cmdParam: releaseArgs, + labels: { + [devboxIdKey]: devboxId + } } + setDeployData(transformData) + const apps = await getAppsByDevboxId(devboxId) - const formData = encodeURIComponent(JSON.stringify(transformData)) + // when: there is no app,create a new app + if (apps.length === 0) { + const tempFormDataStr = encodeURIComponent(JSON.stringify(transformData)) + sealosApp.runEvents('openDesktopApp', { + appKey: 'system-applaunchpad', + pathname: '/redirect', + query: { formData: tempFormDataStr }, + messageData: { + type: 'InternalAppCall', + formData: tempFormDataStr + } + }) + } - sealosApp.runEvents('openDesktopApp', { - appKey: 'system-applaunchpad', - pathname: '/app/edit', - query: { formData }, - messageData: { - type: 'InternalAppCall', - formData: formData - } - }) + // when: there have apps,show the app select modal + if (apps.length >= 1) { + setApps(apps) + setOnOpenSelectApp(true) + } }, [devbox, env.ingressDomain, env.namespace, env.registryAddr] ) @@ -228,6 +249,7 @@ const Version = () => { ) } ] + return ( { devbox={{ ...devbox, sshPort: devbox.sshPort || 0 }} /> )} + {!!onOpenSelectApp && ( + setOnOpenSelectApp(false)} + onClose={() => setOnOpenSelectApp(false)} + /> + )} ) diff --git a/frontend/providers/devbox/app/api/getAppsByDevboxId/route.ts b/frontend/providers/devbox/app/api/getAppsByDevboxId/route.ts new file mode 100644 index 000000000000..de3650a24bac --- /dev/null +++ b/frontend/providers/devbox/app/api/getAppsByDevboxId/route.ts @@ -0,0 +1,54 @@ +import type { NextRequest } from 'next/server' + +import { devboxIdKey } from '@/constants/devbox' +import { authSession } from '@/services/backend/auth' +import { getK8s } from '@/services/backend/kubernetes' +import { jsonRes } from '@/services/backend/response' + +export const dynamic = 'force-dynamic' + +export async function GET(req: NextRequest) { + try { + const apps = await getApps(req) + return jsonRes({ data: apps }) + } catch (err: any) { + return jsonRes({ + code: 500, + error: err + }) + } +} + +async function getApps(req: NextRequest) { + const { searchParams } = req.nextUrl + const devboxId = searchParams.get('devboxId') as string + + const { k8sApp, namespace } = await getK8s({ + kubeconfig: await authSession(req.headers) + }) + + const response = await Promise.allSettled([ + k8sApp.listNamespacedDeployment( + namespace, + undefined, + undefined, + undefined, + undefined, + `${devboxIdKey}=${devboxId}` + ), + k8sApp.listNamespacedStatefulSet( + namespace, + undefined, + undefined, + undefined, + undefined, + `${devboxIdKey}=${devboxId}` + ) + ]) + const apps = response + .filter((item) => item.status === 'fulfilled') + .map((item: any) => item?.value?.body?.items) + .filter((item) => item) + .flat() + return apps +} diff --git a/frontend/providers/devbox/app/api/v1/getDBSecretList/route.ts b/frontend/providers/devbox/app/api/v1/getDBSecretList/route.ts index d01e397a119c..04fa4bad6e53 100644 --- a/frontend/providers/devbox/app/api/v1/getDBSecretList/route.ts +++ b/frontend/providers/devbox/app/api/v1/getDBSecretList/route.ts @@ -94,6 +94,8 @@ const buildConnectionInfo = ( } } +export const dynamic = 'force-dynamic' + export async function GET(req: NextRequest) { try { const { payload, token } = getPayloadWithoutVerification(req.headers) diff --git a/frontend/providers/devbox/components/Icon/icons/rocket.svg b/frontend/providers/devbox/components/Icon/icons/rocket.svg new file mode 100644 index 000000000000..b97d4d81815a --- /dev/null +++ b/frontend/providers/devbox/components/Icon/icons/rocket.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/providers/devbox/components/Icon/index.tsx b/frontend/providers/devbox/components/Icon/index.tsx index 9096d9656636..530ff784ed0c 100644 --- a/frontend/providers/devbox/components/Icon/index.tsx +++ b/frontend/providers/devbox/components/Icon/index.tsx @@ -63,7 +63,8 @@ const map = { check: require('./icons/check.svg').default, empty: require('./icons/empty.svg').default, shutdown: require('./icons/shutdown.svg').default, - windsurf: require('./icons/windsurf.svg').default + windsurf: require('./icons/windsurf.svg').default, + rocket: require('./icons/rocket.svg').default } const MyIcon = ({ diff --git a/frontend/providers/devbox/components/MyTable.tsx b/frontend/providers/devbox/components/MyTable.tsx index fdc2db58f426..0b7025586505 100644 --- a/frontend/providers/devbox/components/MyTable.tsx +++ b/frontend/providers/devbox/components/MyTable.tsx @@ -8,6 +8,7 @@ interface Props extends BoxProps { key: string render?: (item: any) => JSX.Element minWidth?: string + width?: string }[] data: any[] itemClass?: string @@ -18,7 +19,7 @@ const MyTable = ({ columns, data, itemClass = '', alternateRowColors = false }: return ( <> col.width || '1fr').join(' ')} overflowX={'auto'} borderTopRadius={'md'} fontSize={'base'} @@ -41,7 +42,7 @@ const MyTable = ({ columns, data, itemClass = '', alternateRowColors = false }: {data.map((item: any, index1) => ( col.width || '1fr').join(' ')} overflowX={'auto'} key={index1} bg={alternateRowColors ? (index1 % 2 === 0 ? '#FBFBFC' : '#F4F4F7') : 'white'} diff --git a/frontend/providers/devbox/components/modals/AppSelectModal.tsx b/frontend/providers/devbox/components/modals/AppSelectModal.tsx new file mode 100644 index 000000000000..665ca3c5ee5f --- /dev/null +++ b/frontend/providers/devbox/components/modals/AppSelectModal.tsx @@ -0,0 +1,202 @@ +import { + Box, + Flex, + Modal, + ModalBody, + ModalContent, + ModalOverlay, + Button, + ModalHeader, + Text, + Divider +} from '@chakra-ui/react' +import { useTranslations } from 'next-intl' +import { useCallback } from 'react' +import { customAlphabet } from 'nanoid' +import { sealosApp } from 'sealos-desktop-sdk/app' + +import { AppListItemType } from '@/types/app' + +import MyIcon from '../Icon' +import MyTable from '../MyTable' +import { useEnvStore } from '@/stores/env' + +const nanoid = customAlphabet('abcdefghijklmnopqrstuvwxyz', 6) + +interface NetworkConfig { + port: number + protocol: string + openPublicDomain: boolean + domain: string +} + +interface DeployData { + appName: string + cpu: number + memory: number + imageName: string + networks: NetworkConfig[] + runCMD: string + cmdParam: string[] + labels: { + [key: string]: string + } +} + +const AppSelectModal = ({ + apps, + deployData, + devboxName, + onSuccess, + onClose +}: { + apps: AppListItemType[] + devboxName: string + deployData: DeployData + onSuccess: () => void + onClose: () => void +}) => { + const t = useTranslations() + const { env } = useEnvStore() + + const handleCreate = useCallback(() => { + const tempFormData = { ...deployData, appName: `${deployData.appName}-${nanoid()}` } + const tempFormDataStr = encodeURIComponent(JSON.stringify(tempFormData)) + sealosApp.runEvents('openDesktopApp', { + appKey: 'system-applaunchpad', + pathname: '/redirect', + query: { formData: tempFormDataStr }, + messageData: { + type: 'InternalAppCall', + formData: tempFormDataStr + } + }) + }, [deployData]) + + const handleUpdate = useCallback( + (item: AppListItemType) => { + const tempFormData = { appName: item.name, imageName: deployData.imageName } + const tempFormDataStr = encodeURIComponent(JSON.stringify(tempFormData)) + sealosApp.runEvents('openDesktopApp', { + appKey: 'system-applaunchpad', + pathname: '/redirect', + query: { formData: tempFormDataStr }, + messageData: { + type: 'InternalAppCall', + formData: tempFormDataStr + } + }) + onSuccess() + }, + [deployData, onSuccess] + ) + + const columns: { + title: string + dataIndex?: keyof AppListItemType + key: string + width?: string + render?: (item: AppListItemType) => JSX.Element + }[] = [ + { + title: t('app_name'), + dataIndex: 'name', + key: 'name', + render: (item: AppListItemType) => { + return ( + + {item.name} + + ) + } + }, + { + title: t('current_image_name'), + dataIndex: 'imageName', + key: 'imageName', + render: (item: AppListItemType) => { + // note: no same devbox matched image will be dealt. + const dealImageName = item.imageName.startsWith( + `${env.registryAddr}/${env.namespace}/${devboxName}` + ) + ? item.imageName.replace(`${env.registryAddr}/${env.namespace}/`, '') + : '-' + return {dealImageName} + } + }, + { + title: t('create_time'), + dataIndex: 'createTime', + key: 'createTime', + render: (item: AppListItemType) => { + return {item.createTime} + } + }, + { + title: t('control'), + key: 'control', + render: (item: AppListItemType) => ( + + + + ) + } + ] + + return ( + + + + + {t('deploy')} + + + + {t('create_directly')} + + + + + + + + {t('update_matched_apps_notes')} + + + + + + + + + ) +} + +export default AppSelectModal diff --git a/frontend/providers/devbox/constants/devbox.ts b/frontend/providers/devbox/constants/devbox.ts index c1e825d878c3..9d9f86e48415 100644 --- a/frontend/providers/devbox/constants/devbox.ts +++ b/frontend/providers/devbox/constants/devbox.ts @@ -2,8 +2,9 @@ import { DevboxEditType, DevboxDetailType } from '@/types/devbox' export const crLabelKey = 'sealos-devbox-cr' export const devboxKey = 'cloud.sealos.io/devbox-manager' -export const publicDomainKey = `cloud.sealos.io/app-deploy-manager-domain` +export const devboxIdKey = 'cloud.sealos.io/app-devbox-id' export const ingressProtocolKey = 'nginx.ingress.kubernetes.io/backend-protocol' +export const publicDomainKey = `cloud.sealos.io/app-deploy-manager-domain` export enum LanguageTypeEnum { java = 'java', @@ -149,14 +150,14 @@ export const devboxReleaseStatusMap = { [DevboxReleaseStatusEnum.Pending]: { label: 'release_pending', value: DevboxReleaseStatusEnum.Pending, - color: '#787A90', + color: '#0884DD', backgroundColor: '#F5F5F8', dotColor: '#787A90' }, [DevboxReleaseStatusEnum.Failed]: { label: 'release_failed', value: DevboxReleaseStatusEnum.Failed, - color: '#F04438', + color: '#D92D20', backgroundColor: '#FEF3F2', dotColor: '#F04438' } diff --git a/frontend/providers/devbox/deploy/manifests/deploy.yaml.tmpl b/frontend/providers/devbox/deploy/manifests/deploy.yaml.tmpl index a9c9e5343d26..feef4308e724 100644 --- a/frontend/providers/devbox/deploy/manifests/deploy.yaml.tmpl +++ b/frontend/providers/devbox/deploy/manifests/deploy.yaml.tmpl @@ -55,6 +55,8 @@ spec: value: devbox-system - name: INGRESS_DOMAIN value: sealosusw.site + - name: CURRENCY_SYMBOL + value: usd # 'shellCoin' | 'cny' | 'usd' securityContext: runAsNonRoot: true runAsUser: 1001 diff --git a/frontend/providers/devbox/message/en.json b/frontend/providers/devbox/message/en.json index 9c9f12d2bb3a..9fe452701174 100644 --- a/frontend/providers/devbox/message/en.json +++ b/frontend/providers/devbox/message/en.json @@ -20,6 +20,7 @@ "The maximum number of exposed ports is 65535": "Maximum port number is 65535", "The minimum exposed port is 1": "Minimum port number is 1", "This runtime field is required": "The runtime version field is required", + "app_name": "App Name", "basic_configuration": "Basic", "basic_info": "Basic", "cancel": "Cancel", @@ -38,9 +39,11 @@ "cpu_exceeds_quota": "CPU requested exceeds quota. Contact admin.", "create": "Create", "create_devbox": "Create Devbox", + "create_directly": "Want to deploy a new app directly?", "create_failed": "Creation failed", "create_success": "Creation succeeded", "create_time": "Created At", + "current_image_name": "Current Image", "cursor": "Cursor", "daily": "/day", "delete": "Delete", @@ -51,6 +54,7 @@ "delete_warning_content": "Are you sure you want to delete Devbox?", "delete_warning_content_2": "Deleting Devbox will cause the remote environment to be deleted and you will not be able to access the remote development environment. (Your released version will be remaining).", "deploy": "Deploy", + "deploy_a_new_app": "Deploy a new app", "detail": "Detail", "devbox_creation": "Project Creation", "devbox_empty": "You have not created a new devbox yet.", @@ -65,7 +69,7 @@ "edit_successful": "Edit succeeded", "edit_version_description": "Edit Version Description", "enter_devbox_name": "Please enter the devbox name", - "enter_version_description": "Please enter the description", + "enter_version_description": "Please enter the description...", "enter_version_number": "Please enter the tag", "estimated_price": "The Estimated Cost", "event": "Event", @@ -81,6 +85,7 @@ "jump_prompt": "Jump prompt", "jump_terminal_error": "Jump terminal failed", "language": "Language", + "matched_apps": "Deployed apps", "memory": "Memory", "memory_exceeds_quota": "Memory requested exceeds quota. Contact admin.", "monitor": "Monitor", @@ -123,6 +128,7 @@ "runtime": "Runtime", "runtime_environment": "Runtime", "save": "Save", + "select_existing_app": "Update an existing application or deploy a new application...", "shutdown": "Shutdown", "ssh_config": "SSH Configuration", "ssh_connect_info": "SSH Connection String", @@ -138,12 +144,15 @@ "tag_format_error": "Tag format error", "tag_required": "Need to input tag", "terminal": "Terminal", + "to_update": "Update", "total": "Total", "total_price": "Total", "update": "Update", "update Time": "Updated At", + "update_app": "update/create", "update_devbox": "Update devbox", "update_failed": "Update failed", + "update_matched_apps_notes": "Or you can update application: ", "update_success": "Update succeeded", "used": "Used", "version": "Release", diff --git a/frontend/providers/devbox/message/zh.json b/frontend/providers/devbox/message/zh.json index 7a71635534b2..e85d7a73f737 100644 --- a/frontend/providers/devbox/message/zh.json +++ b/frontend/providers/devbox/message/zh.json @@ -20,6 +20,7 @@ "The maximum number of exposed ports is 65535": "暴露端口最大为 65535", "The minimum exposed port is 1": "暴露端口最小为 1", "This runtime field is required": "运行时版本是必填项", + "app_name": "应用名称", "basic_configuration": "基础配置", "basic_info": "基础信息", "cancel": "取消", @@ -38,10 +39,12 @@ "cpu_exceeds_quota": "申请的 CPU 超出限制,请联系管理员", "create": "创建", "create_devbox": "新建项目", + "create_directly": "想要直接上线新应用?", "create_failed": "创建失败", "create_success": "创建成功", "create_time": "创建时间", "creation_time": "创建时间", + "current_image_name": "当前镜像", "cursor": "Cursor", "daily": "/天", "delete": "删除", @@ -52,6 +55,7 @@ "delete_warning_content": "您确定要删除该云沙箱嘛?", "delete_warning_content_2": "删除云沙箱会导致云沙箱远程环境被删除,您将无法访问远程开发环境。(您已发布的版本仍然会保留)。", "deploy": "上线", + "deploy_a_new_app": "上线一个新应用", "detail": "详情", "devbox_creation": "项目创建", "devbox_empty": "您还没有新建项目", @@ -66,7 +70,7 @@ "edit_successful": "编辑成功", "edit_version_description": "编辑版本描述", "enter_devbox_name": "请输入项目名称", - "enter_version_description": "请输入版本描述", + "enter_version_description": "请输入版本描述...", "enter_version_number": "请输入版本号,例如:v1.0.0", "estimated_price": "预估价格", "event": "事件", @@ -83,6 +87,7 @@ "jump_prompt": "跳转提示", "jump_terminal_error": "跳转终端失败", "language": "语言", + "matched_apps": "已部署的同项目应用", "memory": "内存", "memory_exceeds_quota": "申请的 '内存' 超出限制,请联系管理员", "monitor": "实时监控", @@ -125,6 +130,7 @@ "runtime": "运行环境", "runtime_environment": "运行环境", "save": "保存", + "select_existing_app": "更新一个现存的应用或者上线新应用...", "shutdown": "关机", "ssh_config": "SSH 配置", "ssh_connect_info": "连接串", @@ -140,12 +146,15 @@ "tag_format_error": "版本号格式错误", "tag_required": "需要填写版本号", "terminal": "终端", + "to_update": "去更新", "total": "总共", "total_price": "总价格", "update": "变更", "update Time": "更新时间", + "update_app": "更新应用镜像/上线新应用", "update_devbox": "变更项目", "update_failed": "变更失败", + "update_matched_apps_notes": "或者你可以更新已有应用:", "update_success": "变更成功", "used": "已用", "version": "版本", diff --git a/frontend/providers/devbox/types/app.d.ts b/frontend/providers/devbox/types/app.d.ts new file mode 100644 index 000000000000..6345369db147 --- /dev/null +++ b/frontend/providers/devbox/types/app.d.ts @@ -0,0 +1,6 @@ +export interface AppListItemType { + id: string + name: string + createTime: string + imageName: string +} diff --git a/frontend/providers/devbox/utils/adapt.ts b/frontend/providers/devbox/utils/adapt.ts index 50e632ce21fa..979727d70841 100644 --- a/frontend/providers/devbox/utils/adapt.ts +++ b/frontend/providers/devbox/utils/adapt.ts @@ -14,9 +14,10 @@ import { DevboxVersionListItemType, PodDetailType } from '@/types/devbox' -import { V1Ingress, V1Pod } from '@kubernetes/client-node' +import { V1Deployment, V1Ingress, V1Pod, V1StatefulSet } from '@kubernetes/client-node' import { DBListItemType, KbPgClusterType } from '@/types/cluster' import { IngressListItemType } from '@/types/ingress' +import { AppListItemType } from '@/types/app' export const adaptDevboxListItem = (devbox: KBDevboxType): DevboxListItemType => { return { @@ -187,7 +188,6 @@ export const adaptIngressListItem = (ingress: V1Ingress): IngressListItemType => const firstRule = ingress.spec?.rules?.[0] const firstPath = firstRule?.http?.paths?.[0] const protocol = ingress.metadata?.annotations?.['nginx.ingress.kubernetes.io/backend-protocol'] - console.log('ingress', ingress.spec) return { name: ingress.metadata?.name || '', namespace: ingress.metadata?.namespace || '', @@ -196,3 +196,15 @@ export const adaptIngressListItem = (ingress: V1Ingress): IngressListItemType => protocol: protocol || 'http' } } + +export const adaptAppListItem = (app: V1Deployment & V1StatefulSet): AppListItemType => { + return { + id: app.metadata?.uid || ``, + name: app.metadata?.name || 'app name', + createTime: dayjs(app.metadata?.creationTimestamp).format('YYYY/MM/DD HH:mm'), + imageName: + app?.metadata?.annotations?.originImageName || + app.spec?.template?.spec?.containers?.[0]?.image || + '' + } +}