diff --git a/frontend/providers/applaunchpad/src/api/platform.ts b/frontend/providers/applaunchpad/src/api/platform.ts index 406773b82..acfe26093 100644 --- a/frontend/providers/applaunchpad/src/api/platform.ts +++ b/frontend/providers/applaunchpad/src/api/platform.ts @@ -38,3 +38,6 @@ export const getPriceBonus = () => export const checkPermission = (payload: { appName: string }) => GET('/api/platform/checkPermission', payload); + +export const checkReady = (appName: string) => + GET<{ url: string; ready: boolean; error?: string }[]>(`/api/checkReady?appName=${appName}`); diff --git a/frontend/providers/applaunchpad/src/components/app/detail/index/AppMainInfo.tsx b/frontend/providers/applaunchpad/src/components/app/detail/index/AppMainInfo.tsx index b42ae4695..f81b14f70 100644 --- a/frontend/providers/applaunchpad/src/components/app/detail/index/AppMainInfo.tsx +++ b/frontend/providers/applaunchpad/src/components/app/detail/index/AppMainInfo.tsx @@ -11,9 +11,10 @@ import { getUserNamespace } from '@/utils/user'; import { Box, Button, Center, Flex, Grid, Text, useDisclosure } from '@chakra-ui/react'; import dayjs from 'dayjs'; import { useTranslation } from 'next-i18next'; -import { useMemo } from 'react'; +import { useMemo, useRef } from 'react'; import MonitorModal from './MonitorModal'; -import { useNetworkStatus } from '@/hooks/useNetworkStatus'; +import { useQuery } from '@tanstack/react-query'; +import { checkReady } from '@/api/platform'; const AppMainInfo = ({ app = MOCK_APP_DETAIL }: { app: AppDetailType }) => { const { t } = useTranslation(); @@ -34,17 +35,42 @@ const AppMainInfo = ({ app = MOCK_APP_DETAIL }: { app: AppDetailType }) => { })), [app] ); - const networkStatuses = useNetworkStatus(networks); + + const retryCount = useRef(0); + const { data: networkStatus, refetch } = useQuery({ + queryKey: ['networkStatus', app.appName], + queryFn: () => checkReady(app.appName), + retry: 5, + retryDelay: (attemptIndex) => Math.min(1000 * Math.pow(2, attemptIndex), 30000), + onSuccess: (data) => { + const hasUnready = data.some((item) => !item.ready); + if (!hasUnready) { + retryCount.current = 0; + return; + } + if (retryCount.current < 14) { + const delay = Math.min(1000 * Math.pow(2, retryCount.current), 32000); + retryCount.current += 1; + setTimeout(() => { + refetch(); + }, delay); + } + }, + refetchIntervalInBackground: false, + staleTime: 1000 * 60 * 5 + }); const statusMap = useMemo( () => - networkStatuses.reduce((acc, status) => { - if (status.data?.url) { - acc[status.data.url] = status.data; - } - return acc; - }, {} as Record), - [networkStatuses] + networkStatus + ? networkStatus.reduce((acc, item) => { + if (item?.url) { + acc[item.url] = item; + } + return acc; + }, {} as Record) + : {}, + [networkStatus] ); return ( @@ -123,17 +149,17 @@ const AppMainInfo = ({ app = MOCK_APP_DETAIL }: { app: AppDetailType }) => { - + {network.public && ( <> - {statusMap[network.public]?.isReady ? ( + {statusMap[network.public]?.ready ? (
@@ -152,7 +178,7 @@ const AppMainInfo = ({ app = MOCK_APP_DETAIL }: { app: AppDetailType }) => { bg={'rgba(17, 24, 36, 0.05)'} color={'#485264'} borderRadius={'full'} - p={'2px 4px'} + p={'2px 8px 2px 4px'} gap={'2px'} minW={'63px'} > @@ -200,6 +226,7 @@ const AppMainInfo = ({ app = MOCK_APP_DETAIL }: { app: AppDetailType }) => { {!!network.public && (
{ - const urlPairs = networks - .map((network) => network.public) - .filter(Boolean) - .map((originalUrl) => ({ - originalUrl, - fetchUrl: originalUrl.replace(/^(wss|grpcs):\/\//, 'https://') - })); - - return useQueries({ - queries: urlPairs.map(({ originalUrl, fetchUrl }) => ({ - queryKey: ['networkStatus', originalUrl], - queryFn: async (): Promise => { - const response = await fetch(`/api/check-ready?url=${encodeURIComponent(fetchUrl)}`); - const data: ApiResponse = await response.json(); - if (data.code !== 200) { - throw new Error(data.message || data.error || 'Service not ready'); - } - - return { - url: originalUrl, - isReady: data.data?.ready ?? false - }; - }, - retry: 5, - retryDelay: (attemptIndex: number) => Math.min(1000 * Math.pow(2, attemptIndex), 30000), - refetchIntervalInBackground: false, - staleTime: 1000 * 60 * 5 - })) - } as const); -}; diff --git a/frontend/providers/applaunchpad/src/pages/api/check-ready.ts b/frontend/providers/applaunchpad/src/pages/api/check-ready.ts deleted file mode 100644 index 0e2919753..000000000 --- a/frontend/providers/applaunchpad/src/pages/api/check-ready.ts +++ /dev/null @@ -1,46 +0,0 @@ -import { authSession } from '@/services/backend/auth'; -import { getK8s } from '@/services/backend/kubernetes'; -import { jsonRes } from '@/services/backend/response'; -import { NextApiRequest, NextApiResponse } from 'next'; - -export default async function handler(request: NextApiRequest, res: NextApiResponse) { - try { - const { url } = request.query as { url: string }; - if (!url) { - return jsonRes(res, { - code: 400, - error: '缺少 URL 参数' - }); - } - - const response = await fetch(url); - - if (response.status === 503) { - return jsonRes(res, { - code: 503, - message: 'Service Unavailable (503)' - }); - } - - const text = await response.text(); - if (text.includes('upstream not health')) { - return jsonRes(res, { - code: 503, - message: 'Upstream not healthy' - }); - } - - return jsonRes(res, { - code: 200, - data: { - ready: true - } - }); - } catch (error: any) { - console.error(error); - return jsonRes(res, { - code: 500, - error: error?.message - }); - } -} diff --git a/frontend/providers/applaunchpad/src/pages/api/checkReady.ts b/frontend/providers/applaunchpad/src/pages/api/checkReady.ts new file mode 100644 index 000000000..f9a98736a --- /dev/null +++ b/frontend/providers/applaunchpad/src/pages/api/checkReady.ts @@ -0,0 +1,80 @@ +import { appDeployKey, ProtocolList } from '@/constants/app'; +import { authSession } from '@/services/backend/auth'; +import { getK8s } from '@/services/backend/kubernetes'; +import { jsonRes } from '@/services/backend/response'; +import { ProtocolType } from '@/types/app'; +import { NextApiRequest, NextApiResponse } from 'next'; + +export default async function handler(req: NextApiRequest, res: NextApiResponse) { + try { + const { appName } = req.query as { appName: string }; + const port = global.AppConfig.cloud.port; + if (!appName) { + throw new Error('appName is empty'); + } + + const { k8sNetworkingApp, namespace } = await getK8s({ + kubeconfig: await authSession(req.headers) + }); + + const ingress = await k8sNetworkingApp.listNamespacedIngress( + namespace, + undefined, + undefined, + undefined, + undefined, + `${appDeployKey}=${appName}` + ); + + if (!ingress.body.items || ingress.body.items.length === 0) { + throw new Error('No ingress found'); + } + + const checkResults = await Promise.all( + ingress.body.items.map(async (item) => { + if (!item.spec?.rules?.[0]) { + return { ready: false, url: '/', error: 'Invalid ingress configuration' }; + } + + const rule = item.spec.rules[0]; + const host = rule.host; + const backendProtocol = item?.metadata?.annotations?.[ + 'nginx.ingress.kubernetes.io/backend-protocol' + ] as ProtocolType; + + const fetchUrl = `http://${host}`; + const protocol = + ProtocolList.find((item) => item.value === backendProtocol)?.label || 'https://'; + const url = `${protocol}${host}${port ? `${port}` : ''}`; + + try { + const response = await fetch(fetchUrl); + + if (response.status === 503) { + return { ready: false, url, error: 'Service Unavailable (503)' }; + } + + const text = await response.text(); + if (text.includes('upstream not health')) { + return { ready: false, url, error: 'Upstream not healthy' }; + } + + return { ready: true, url }; + } catch (error) { + return { ready: false, url, error: 'fetch error' }; + } + }) + ); + + return jsonRes(res, { + code: 200, + data: checkResults + }); + } catch (error: any) { + console.error(error); + return jsonRes(res, { + code: 500, + error: error?.message + }); + } +} diff --git a/frontend/providers/devbox/api/platform.ts b/frontend/providers/devbox/api/platform.ts index 47ac9c08e..cacb45398 100644 --- a/frontend/providers/devbox/api/platform.ts +++ b/frontend/providers/devbox/api/platform.ts @@ -25,3 +25,8 @@ export const checkUserTask = () => POST('/api/guide/checkTask', { desktopToAppToken: getDesktopSessionFromSessionStorage()?.token }); + +export const checkReady = (devboxName: string) => + GET<{ url: string; ready: boolean; error?: string }[]>( + `/api/checkReady?devboxName=${devboxName}` + ); 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 0884643b0..14db6ebb7 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 @@ -1,4 +1,4 @@ -import { Box, Button, Flex, Text, Tooltip, useDisclosure } from '@chakra-ui/react'; +import { Box, Button, Center, Flex, Text, Tooltip, useDisclosure } from '@chakra-ui/react'; import dayjs from 'dayjs'; import { useTranslations } from 'next-intl'; import dynamic from 'next/dynamic'; @@ -12,6 +12,9 @@ import { useCopyData } from '@/utils/tools'; import { useDevboxStore } from '@/stores/devbox'; import { useEnvStore } from '@/stores/env'; +import { useMemo, useRef } from 'react'; +import { useQuery } from '@tanstack/react-query'; +import { checkReady } from '@/api/platform'; const MonitorModal = dynamic(() => import('@/components/modals/MonitorModal')); @@ -22,6 +25,43 @@ const MainBody = () => { const { env } = useEnvStore(); const { isOpen, onOpen, onClose } = useDisclosure(); + const retryCount = useRef(0); + const { data: networkStatus, refetch } = useQuery({ + queryKey: ['networkStatus', devboxDetail?.name], + queryFn: () => (devboxDetail?.name ? checkReady(devboxDetail?.name) : []), + retry: 5, + retryDelay: (attemptIndex) => Math.min(1000 * Math.pow(2, attemptIndex), 30000), + onSuccess: (data) => { + const hasUnready = data.some((item) => !item.ready); + if (!hasUnready) { + retryCount.current = 0; + return; + } + if (retryCount.current < 14) { + const delay = Math.min(1000 * Math.pow(2, retryCount.current), 32000); + retryCount.current += 1; + setTimeout(() => { + refetch(); + }, delay); + } + }, + refetchIntervalInBackground: false, + staleTime: 1000 * 60 * 5 + }); + + const statusMap = useMemo( + () => + networkStatus + ? networkStatus.reduce((acc, item) => { + if (item?.url) { + acc[item.url] = item; + } + return acc; + }, {} as Record) + : {}, + [networkStatus] + ); + const networkColumn: { title: string; dataIndex?: keyof NetworkType; @@ -78,33 +118,85 @@ const MainBody = () => { render: (item: NetworkType) => { if (item.openPublicDomain) { const address = item.customDomain || item.publicDomain; + const displayAddress = `https://${address}`; return ( - - window.open(`https://${address}`, '_blank')} + + {displayAddress && ( + <> + {statusMap[displayAddress]?.ready ? ( +
+
+ {t('Accessible')} +
+ ) : ( +
+ + {t('prepare')} +
+ )} + + )} + - https://{address} -
-
+ window.open(`https://${address}`, '_blank')} + > + https://{address} + + + ); } return -; } } ]; + return ( {/* monitor */} diff --git a/frontend/providers/devbox/app/api/checkReady/route.ts b/frontend/providers/devbox/app/api/checkReady/route.ts new file mode 100644 index 000000000..629807d73 --- /dev/null +++ b/frontend/providers/devbox/app/api/checkReady/route.ts @@ -0,0 +1,78 @@ +import { devboxKey } from '@/constants/devbox'; +import { authSession } from '@/services/backend/auth'; +import { getK8s } from '@/services/backend/kubernetes'; +import { jsonRes } from '@/services/backend/response'; +import { ProtocolType } from '@/types/devbox'; +import { NextRequest, NextResponse } from 'next/server'; + +export const dynamic = 'force-dynamic'; + +export async function GET(request: NextRequest) { + try { + const devboxName = request.nextUrl.searchParams.get('devboxName'); + + if (!devboxName) { + throw new Error('devboxName is empty'); + } + + const { k8sNetworkingApp, namespace } = await getK8s({ + kubeconfig: await authSession(request.headers) + }); + const label = `${devboxKey}=${devboxName}`; + const ingress = await k8sNetworkingApp.listNamespacedIngress( + namespace, + undefined, + undefined, + undefined, + undefined, + label + ); + + if (!ingress.body.items || ingress.body.items.length === 0) { + throw new Error('No ingress found'); + } + + const checkResults = await Promise.all( + ingress.body.items.map(async (item) => { + if (!item.spec?.rules?.[0]) { + return { ready: false, url: '/', error: 'Invalid ingress configuration' }; + } + + const rule = item.spec.rules[0]; + const host = rule.host; + const fetchUrl = `http://${host}`; + const url = `https://${host}`; + + try { + const response = await fetch(fetchUrl, { + cache: 'no-store' + }); + + if (response.status === 503) { + return { ready: false, url, error: 'Service Unavailable (503)' }; + } + + const text = await response.text(); + if (text.includes('upstream not health')) { + return { ready: false, url, error: 'Upstream not healthy' }; + } + + return { ready: true, url }; + } catch (error) { + return { ready: false, url, error: 'fetch error' }; + } + }) + ); + + return jsonRes({ + code: 200, + data: checkResults + }); + } catch (error: any) { + console.error(error); + return jsonRes({ + code: 500, + error: error?.message + }); + } +} diff --git a/frontend/providers/devbox/components/Icon/icons/loadingCircle.svg b/frontend/providers/devbox/components/Icon/icons/loadingCircle.svg new file mode 100644 index 000000000..257c1b5aa --- /dev/null +++ b/frontend/providers/devbox/components/Icon/icons/loadingCircle.svg @@ -0,0 +1,10 @@ + + + + + + + + + + \ No newline at end of file diff --git a/frontend/providers/devbox/components/Icon/index.tsx b/frontend/providers/devbox/components/Icon/index.tsx index 3b6a971bc..7a830ae1d 100644 --- a/frontend/providers/devbox/components/Icon/index.tsx +++ b/frontend/providers/devbox/components/Icon/index.tsx @@ -85,7 +85,8 @@ const map = { webstorm: require('./icons/jetbrains/webstorm.svg').default, gift: require('./icons/gift.svg').default, fileConfig: require('./icons/fileConfig.svg').default, - trae: require('./icons/trae.svg').default + trae: require('./icons/trae.svg').default, + loadingCircle: require('./icons/loadingCircle.svg').default }; const MyIcon = ({ diff --git a/frontend/providers/devbox/message/en.json b/frontend/providers/devbox/message/en.json index 1ab19b6fa..86e269950 100644 --- a/frontend/providers/devbox/message/en.json +++ b/frontend/providers/devbox/message/en.json @@ -1,4 +1,5 @@ { + "Accessible": "Accessible", "Add Port": "Add Port", "Amount": "Amount", "CNAME Tips": "To set up a custom domain, please add a `CNAME` record for your domain pointing to {domain} at your domain registrar. You can bind your custom domain once the DNS resolution takes effect.", @@ -8,6 +9,7 @@ "Delete": "Deleting", "Error": "Error", "Failed": "Failed", + "GPU": "GPU", "Gpu under inventory Tip": "{gputype} is out of stock. Please select a different type or reduce the quantity", "Input your custom domain": "Enter your custom domain", "Inventory": "Stock", @@ -101,6 +103,8 @@ "export_yaml": "Export YAML", "external_address": "Public Address", "framework": "Framework", + "gpu": "GPU", + "gpu_exceeds_quota": "Requested GPU exceeds your quota. Please contact admin.", "guide": { "click_shadow_skip": "Click on any shadow to skip", "deploy_cost": "Current resource usage and estimated daily consumption", @@ -193,6 +197,7 @@ "pause_success": "Shutdown succeeded", "please_enter_devbox_name_confirm": "Please enter {name} to confirm: ", "port": "Port", + "prepare": "Waiting", "privacy_and_security_agreement": "Privacy and Security Agreement", "privacy_and_security_agreement_tips": "Please read and agree to the Privacy and Security Agreement.", "private": "Private", @@ -284,8 +289,5 @@ "vm": "vm", "vscode": "VS Code", "vscode_tooltip": "Click to develop in VSCode", - "yaml_file": "YAML", - "gpu_exceeds_quota": "Requested GPU exceeds your quota. Please contact admin.", - "GPU": "GPU", - "gpu": "GPU" + "yaml_file": "YAML" } diff --git a/frontend/providers/devbox/message/zh.json b/frontend/providers/devbox/message/zh.json index b085e8cb7..3016badd7 100644 --- a/frontend/providers/devbox/message/zh.json +++ b/frontend/providers/devbox/message/zh.json @@ -1,4 +1,5 @@ { + "Accessible": "可访问", "Add Port": "添加端口", "Amount": "数量", "CNAME Tips": "请到您的域名服务商处,添加该域名的 `CNAME` 解析到 {domain},解析生效后即可绑定自定义域名。", @@ -8,6 +9,7 @@ "Delete": "删除中", "Error": "错误", "Failed": "失败", + "GPU": "GPU", "Gpu under inventory Tip": "{gputype} 库存不足,请更换型号或减少数量", "Input your custom domain": "输入您的自定义域名", "Inventory": "库存", @@ -102,6 +104,8 @@ "export_yaml": "导出 YAML", "external_address": "公网地址", "framework": "框架", + "gpu": "GPU", + "gpu_exceeds_quota": "申请的 GPU 超出配额限制,请联系管理员", "guide": { "click_shadow_skip": "点击任意阴影跳过", "deploy_cost": "当前资源,预估单日消耗", @@ -141,8 +145,8 @@ "jetbrains_guide_move_to_path": "将下载好的私钥移动到目录:~/.ssh/sealos/ 。", "jetbrains_guide_one_click_setup": "一键配置", "jetbrains_guide_one_click_setup_desc": "点击上方按钮 下载 bat/sh 脚本,下载到本地后直接双击运行即可完成 SSH 连接信息的配置。", - "jetbrains_guide_one_click_setup_desc_windows": "点击上方按钮 下载 bat/sh 脚本,下载到本地后右键点击文件 > 使用 PowerShell 运行 即可完成 SSH 连接信息的配置。", "jetbrains_guide_one_click_setup_desc_2": "或者,您也可以复制下方脚本到本地命令行,然后直接执行。", + "jetbrains_guide_one_click_setup_desc_windows": "点击上方按钮 下载 bat/sh 脚本,下载到本地后右键点击文件 > 使用 PowerShell 运行 即可完成 SSH 连接信息的配置。", "jetbrains_guide_post_connection": "后续使用可点击下方按钮进行查看。", "jetbrains_guide_post_use": "后续使用", "jetbrains_guide_prepare": "前置准备", @@ -195,6 +199,7 @@ "pause_success": "关机成功", "please_enter_devbox_name_confirm": "请输入 {name} 确认:", "port": "端口", + "prepare": "待启动", "privacy_and_security_agreement": "安全隐私说明", "privacy_and_security_agreement_tips": "请阅读并同意安全隐私协议。", "private": "私有", @@ -286,8 +291,5 @@ "vscode": "VS Code", "vscodeInsider": "VSCode Insider", "vscode_tooltip": "点击在 VSCode 中开发", - "yaml_file": "YAML 文件", - "gpu_exceeds_quota": "申请的 GPU 超出配额限制,请联系管理员", - "GPU": "GPU", - "gpu": "GPU" + "yaml_file": "YAML 文件" }