diff --git a/frontend/providers/devbox/api/devbox.ts b/frontend/providers/devbox/api/devbox.ts index 9f94747da1e..944194fd078 100644 --- a/frontend/providers/devbox/api/devbox.ts +++ b/frontend/providers/devbox/api/devbox.ts @@ -9,7 +9,6 @@ import { DevboxVersionListItemType } from '@/types/devbox' import { KBDevboxReleaseType, KBDevboxTypeV2 } from '@/types/k8s' -import { MonitorDataResult, MonitorQueryKey } from '@/types/monitor' import { adaptAppListItem, adaptDevboxDetailV2, @@ -17,14 +16,21 @@ import { adaptDevboxVersionListItem, adaptPod } from '@/utils/adapt' +import { MonitorDataResult, MonitorQueryKey } from '@/types/monitor' +import { AxiosProgressEvent } from 'axios' export const getMyDevboxList = () => - GET<[KBDevboxTypeV2, { - templateRepository: { - iconId: string | null; - }; - uid: string; - }][]>('/api/getDevboxList').then((data): DevboxListItemTypeV2[] => + 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() }) @@ -35,9 +41,8 @@ export const getDevboxByName = (devboxName: string) => export const applyYamlList = (yamlList: string[], type: 'create' | 'replace' | 'update') => POST('/api/applyYamlList', { yamlList, type }) -export const createDevbox = (payload: { - devboxForm: DevboxEditTypeV2 -}) => POST(`/api/createDevbox`, payload) +export const createDevbox = (payload: { devboxForm: DevboxEditTypeV2 }) => + POST(`/api/createDevbox`, payload) export const updateDevbox = (payload: { patch: DevboxPatchPropsType; devboxName: string }) => POST(`/api/updateDevbox`, payload) @@ -73,14 +78,14 @@ export const delDevboxVersionByName = (versionName: string) => export const getSSHConnectionInfo = (data: { devboxName: string }) => GET<{ - base64PublicKey: string; - base64PrivateKey: string; - token: string; - userName: string; - workingDir: string; - releaseCommand: string; - releaseArgs: string; -}>('/api/getSSHConnectionInfo', data) + 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)) @@ -95,3 +100,17 @@ export const getAppsByDevboxId = (devboxId: string) => GET('/api/getAppsByDevboxId', { devboxId }).then((res) => res.map(adaptAppListItem) ) + +export const execCommandInDevboxPod = (data: { + devboxName: string + command: string + idePath: string + onDownloadProgress: (progressEvent: AxiosProgressEvent) => void + signal: AbortSignal +}) => + POST('/api/execCommandInDevboxPod', data, { + // responseType: 'stream', + timeout: 0, + onDownloadProgress: data.onDownloadProgress, + signal: data.signal + }) diff --git a/frontend/providers/devbox/api/template.ts b/frontend/providers/devbox/api/template.ts index 454568a3fc2..2648f06fbe5 100644 --- a/frontend/providers/devbox/api/template.ts +++ b/frontend/providers/devbox/api/template.ts @@ -1,20 +1,29 @@ -import { Tag, TemplateRepositoryKind } from "@/prisma/generated/client"; -import { DELETE, GET, POST } from "@/services/request"; -import { CreateTemplateRepositoryType, UpdateTemplateRepositoryType, UpdateTemplateType } from "@/utils/vaildate"; +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) => { +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) => { @@ -26,35 +35,34 @@ export const listTemplateRepository = (page: { if (search) searchParams.append('search', search) return GET<{ templateRepositoryList: { - uid: string; - name: string; - description: string | null; - iconId: string | null; + uid: string + name: string + description: string | null + iconId: string | null templates: { - uid: string; - name: string; - }[]; + uid: string + name: string + }[] templateRepositoryTags: { - tag: Tag; - }[]; - }[], + tag: Tag + }[] + }[] page: { - page: number, - pageSize: number, - totalItems: number, - totalPage: number, + page: number + pageSize: number + totalItems: number + totalPage: number } }>(`/api/templateRepository/list?${searchParams.toString()}`) - } export const listPrivateTemplateRepository = ({ search, page, - pageSize, + pageSize }: { - search?: string, - page?: number, - pageSize?: number, + search?: string + page?: number + pageSize?: number } = {}) => { const searchParams = new URLSearchParams() @@ -63,70 +71,79 @@ export const listPrivateTemplateRepository = ({ if (pageSize) searchParams.append('pageSize', pageSize.toString()) return GET<{ templateRepositoryList: { - uid: string; - name: string; - description: string | null; - iconId: string | null; + uid: string + name: string + description: string | null + iconId: string | null templates: { - uid: string; - name: string; - }[]; - isPublic: boolean; + uid: string + name: string + }[] + isPublic: boolean templateRepositoryTags: { - tag: Tag; - }[]; - }[], + tag: Tag + }[] + }[] page: { - page: number, - pageSize: number, - totalItems: number, - totalPage: number, + 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 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 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 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 +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}`) 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 7c0ff942e5b..cc97d2010be 100644 --- a/frontend/providers/devbox/app/[lang]/(platform)/(home)/components/DevboxList.tsx +++ b/frontend/providers/devbox/app/[lang]/(platform)/(home)/components/DevboxList.tsx @@ -10,12 +10,12 @@ import { useRouter } from '@/i18n'; import { useGlobalStore } from '@/stores/global'; import { DevboxListItemTypeV2 } from '@/types/devbox'; -import DevboxStatusTag from '@/components/DevboxStatusTag'; import MyIcon from '@/components/Icon'; import IDEButton from '@/components/IDEButton'; -import ReleaseModal from '@/components/modals/releaseModal'; import PodLineChart from '@/components/PodLineChart'; import { AdvancedTable } from '@/components/AdvancedTable'; +import DevboxStatusTag from '@/components/DevboxStatusTag'; +import ReleaseModal from '@/components/modals/ReleaseModal'; const DelModal = dynamic(() => import('@/components/modals/DelModal')); @@ -29,9 +29,6 @@ const DevboxList = ({ const router = useRouter(); const t = useTranslations(); const { message: toast } = useMessage(); - const duplicatedDevboxList = Array(20) - .fill(0) - .flatMap(() => [...devboxList]); // TODO: Unified Loading Behavior const { setLoading } = useGlobalStore(); @@ -224,6 +221,7 @@ const DevboxList = ({ sshPort={item.sshPort} status={item.status} mr={'8px'} + runtimeType={item.template.templateRepository.iconId as string} /> @@ -173,13 +202,15 @@ const BasicInfo = () => { fontSize={'12px'} fontWeight={400} py={2} - borderRadius={'md'}> + borderRadius={'md'} + > + w={'full'} + > {`ssh -i yourPrivateKeyPath ${devboxDetail?.sshConfig?.sshUser}@${env.sealosDomain} -p ${devboxDetail?.sshPort}`} @@ -201,19 +232,28 @@ const BasicInfo = () => { fontSize={'12px'} fontWeight={400} py={2} - borderRadius={'md'}> + borderRadius={'md'} + > + }} + > handleDownloadConfig(devboxDetail?.sshConfig)} + onClick={() => + downLoadBlob( + devboxDetail?.sshConfig?.sshPrivateKey as string, + 'application/octet-stream', + `${devboxDetail?.name}` + ) + } /> @@ -258,14 +298,16 @@ const BasicInfo = () => { fontSize={'12px'} fontWeight={400} py={2} - borderRadius={'md'}> + borderRadius={'md'} + > + }} + > { + {onOpenSsHConnect && sshConfigData && ( + { + setOnOpenSsHConnect(false); + }} + onClose={() => { + setOnOpenSsHConnect(false); + }} + /> + )} - ) -} + ); +}; -export default BasicInfo +export default BasicInfo; 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 8e920f49e10..2d5979e1c0c 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 @@ -168,6 +168,7 @@ const Header = ({ { listPrivateTemplateRepositoryQuery.data?.templateRepositoryList || [] const handleDeploy = useCallback( async (version: DevboxVersionListItemType) => { - // const { releaseCommand, releaseArgs } = await getSSHRuntimeInfo(devbox.runtimeVersion) if (!devbox) return const result = await getTemplateConfig(devbox.templateUid) const config = parseTemplateConfig(result.template.config) @@ -260,7 +259,12 @@ const Version = () => { + { const response = createSealosApp() - ; (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') - })() - } + ;(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') + })() + } + } + })() return response // eslint-disable-next-line react-hooks/exhaustive-deps }, []) @@ -87,18 +86,18 @@ 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: 'en' + }) + } + })() return sealosApp?.addAppEventListen(EVENT_NAME.CHANGE_I18N, changeI18n) // eslint-disable-next-line react-hooks/exhaustive-deps diff --git a/frontend/providers/devbox/app/api/execCommandInDevboxPod/route.ts b/frontend/providers/devbox/app/api/execCommandInDevboxPod/route.ts new file mode 100644 index 00000000000..8e9756e9d11 --- /dev/null +++ b/frontend/providers/devbox/app/api/execCommandInDevboxPod/route.ts @@ -0,0 +1,195 @@ +import { NextRequest } from 'next/server'; +import { PassThrough, Readable, Writable } from 'stream'; + +import { authSession } from '@/services/backend/auth'; +import { getK8s } from '@/services/backend/kubernetes'; +import { jsonRes } from '@/services/backend/response'; +import { NextApiResponse } from 'next'; + +export const dynamic = 'force-dynamic'; + +async function execCommand( + k8sExec: any, + namespace: string, + podName: string, + containerName: string, + command: string[], + stdin: Readable | null = null, + stdout: Writable | null = null, + stderr: Writable | null = null +) { + return new Promise(async (resolve, reject) => { + let chunks = Buffer.alloc(0); + + if (!stdout) { + stdout = new PassThrough(); + } + + const free = () => { + stderr?.removeAllListeners(); + stdout?.removeAllListeners(); + stdin?.removeAllListeners(); + }; + + stdout.on('end', () => { + free(); + console.log('stdout end'); + resolve('success'); + }); + + stdout.on('error', (error) => { + free(); + reject(error); + }); + + if (!stderr) { + stderr = new PassThrough(); + } + + stderr.on('data', (chunk) => { + stdout?.write(chunk); + }); + stderr.on('end', () => { + console.log('stderr end'); + resolve('success'); + }); + + const WebSocket = await k8sExec.exec( + namespace, + podName, + containerName, + command, + stdout, + stderr, + stdin, + false + ); + + WebSocket.on('close', () => { + resolve('success upload, close web socket'); + }); + WebSocket.on('error', (error: any) => { + console.error('WebSocket error:', error); + reject(error); + }); + + if (stdin) { + stdin.on('end', () => { + free(); + }); + } + }); +} + +export async function POST(req: NextRequest) { + try { + const { devboxName, command, idePath } = (await req.json()) as { + devboxName: string; + command: string; + idePath: string; + }; + + const headerList = req.headers; + + const { namespace, k8sCore, k8sExec } = await getK8s({ + kubeconfig: await authSession(headerList) + }); + + // get pods + const { + body: { items: pods } + } = await k8sCore.listNamespacedPod( + namespace, + undefined, + undefined, + undefined, + undefined, + `app.kubernetes.io/name=${devboxName}` + ); + + const podName = pods[0].metadata?.name; + const containerName = pods[0].spec?.containers[0].name; + + if (!podName || !containerName) { + return jsonRes({ + code: 500, + error: 'Pod or container not found' + }); + } + + const processStream = new PassThrough(); + const { readable, writable } = new TransformStream(); + const writer = writable.getWriter(); + let isStreamClosed = false; + + const execPromise = execCommand( + k8sExec, + namespace, + podName, + containerName, + ['/bin/sh', '-c', command], + null, + processStream, + null + ); + + processStream.on('data', (chunk) => writer.write(chunk)); + processStream.on('end', async () => { + console.log('processStream end'); + if (!isStreamClosed) { + isStreamClosed = true; + await writer.close(); + } + return jsonRes({ + code: 200, + data: 'success' + }); + }); + + processStream.on('error', async (error) => { + console.error('Process stream error:', error); + if (!isStreamClosed) { + isStreamClosed = true; + await writer.close(); + } + }); + + execPromise.finally(async () => { + processStream.end(); + processStream.destroy(); + if (!isStreamClosed) { + isStreamClosed = true; + await writer.close(); + } + console.log('execPromise end'); + }); + + req.signal.addEventListener('abort', async () => { + console.log('Connection aborted by client'); + processStream.destroy(); + if (!isStreamClosed) { + isStreamClosed = true; + await writer.close(); + } + execCommand(k8sExec, namespace, podName, containerName, [ + '/bin/sh', + '-c', + `rm -rf ${idePath}` + ]); + }); + + return new Response(readable, { + headers: { + 'Content-Type': 'text/event-stream;charset=utf-8', + 'Access-Control-Allow-Origin': '*', + 'X-Accel-Buffering': 'no', + 'Cache-Control': 'no-cache, no-transform' + } + }); + } catch (err: any) { + return jsonRes({ + code: 500, + error: err + }); + } +} diff --git a/frontend/providers/devbox/app/api/getSSHConnectionInfo/route.ts b/frontend/providers/devbox/app/api/getSSHConnectionInfo/route.ts index c672fd5004c..96738265281 100644 --- a/frontend/providers/devbox/app/api/getSSHConnectionInfo/route.ts +++ b/frontend/providers/devbox/app/api/getSSHConnectionInfo/route.ts @@ -13,12 +13,9 @@ export async function GET(req: NextRequest) { try { const { searchParams } = req.nextUrl const devboxName = searchParams.get('devboxName') as string - // const runtimeName = searchParams.get('runtimeName') as string const headerList = req.headers - const { ROOT_RUNTIME_NAMESPACE } = process.env - const { k8sCore, namespace, k8sCustomObjects } = await getK8s({ kubeconfig: await authSession(headerList) }) @@ -46,12 +43,18 @@ export async function GET(req: NextRequest) { } }) if (!template) throw new Error(`Template ${devboxBody.spec.templateID} is not found`) - const config = parseTemplateConfig(template.config) - return jsonRes({ data: { base64PublicKey, base64PrivateKey, token, - userName: config.user, - workingDir: config.workingDir, - releaseCommand: config.releaseCommand.join(' '), - releaseArgs: config.releaseArgs.join(' ') } }) + const config = parseTemplateConfig(template.config) + return jsonRes({ + data: { + base64PublicKey, + base64PrivateKey, + token, + userName: config.user, + workingDir: config.workingDir, + releaseCommand: config.releaseCommand.join(' '), + releaseArgs: config.releaseArgs.join(' ') + } + }) } catch (err: any) { return jsonRes({ code: 500, diff --git a/frontend/providers/devbox/app/api/getSSHRuntimeInfo/route.ts b/frontend/providers/devbox/app/api/getSSHRuntimeInfo/route.ts deleted file mode 100644 index 02ff17760f0..00000000000 --- a/frontend/providers/devbox/app/api/getSSHRuntimeInfo/route.ts +++ /dev/null @@ -1,43 +0,0 @@ -import { NextRequest } from 'next/server' - -import { authSession } from '@/services/backend/auth' -import { getK8s } from '@/services/backend/kubernetes' -import { jsonRes } from '@/services/backend/response' -import { defaultEnv } from '@/stores/env' -import { KBRuntimeType } from '@/types/k8s' - -export const dynamic = 'force-dynamic' - -export async function GET(req: NextRequest) { - try { - const { searchParams } = req.nextUrl - const runtimeName = searchParams.get('runtimeName') as string - const headerList = req.headers - const { ROOT_RUNTIME_NAMESPACE } = process.env - - const { k8sCustomObjects } = await getK8s({ - kubeconfig: await authSession(headerList) - }) - - const { body: runtime } = (await k8sCustomObjects.getNamespacedCustomObject( - 'devbox.sealos.io', - 'v1alpha1', - ROOT_RUNTIME_NAMESPACE || defaultEnv.rootRuntimeNamespace, - 'runtimes', - runtimeName - )) as { body: KBRuntimeType } - - const data = { - workingDir: runtime.spec.config.workingDir, - releaseCommand: runtime.spec.config.releaseCommand.join(' '), - releaseArgs: runtime.spec.config.releaseArgs.join(' ') - } - - return jsonRes({ data }) - } catch (err: any) { - return jsonRes({ - code: 500, - error: err - }) - } -} diff --git a/frontend/providers/devbox/app/api/v1/getDBSecretList/route.ts b/frontend/providers/devbox/app/api/v1/getDBSecretList/route.ts index 04fa4bad6e5..ef0f4f39495 100644 --- a/frontend/providers/devbox/app/api/v1/getDBSecretList/route.ts +++ b/frontend/providers/devbox/app/api/v1/getDBSecretList/route.ts @@ -191,8 +191,6 @@ export async function GET(req: NextRequest) { const dbListResult = await Promise.all(dbList) - console.log('dbListResult', dbListResult) - return jsonRes({ data: { dbList: dbListResult diff --git a/frontend/providers/devbox/components/Code.tsx b/frontend/providers/devbox/components/Code.tsx new file mode 100644 index 00000000000..77a34f48ebd --- /dev/null +++ b/frontend/providers/devbox/components/Code.tsx @@ -0,0 +1,55 @@ +import { useMemo } from 'react' +import { Box } from '@chakra-ui/react' +import ReactMarkdown from 'react-markdown' +import SyntaxHighlighter from 'react-syntax-highlighter' + +import { codeTheme } from '@/constants/hljs' + +type TMarkDown = { + content: string + language: string + [key: string]: any +} + +const Code = ({ content, language, ...props }: TMarkDown) => { + const code = useMemo(() => '```' + language + '\n' + content + '```', [content, language]) + + return ( + + + ) : ( + + {children} + + ) + } + }} + /> + + ) +} + +export default Code diff --git a/frontend/providers/devbox/components/IDEButton.tsx b/frontend/providers/devbox/components/IDEButton.tsx index ef063e1224b..62c92c7ad3f 100644 --- a/frontend/providers/devbox/components/IDEButton.tsx +++ b/frontend/providers/devbox/components/IDEButton.tsx @@ -15,14 +15,16 @@ import { useMessage } from '@sealos/ui' import { useTranslations } from 'next-intl' import { useCallback, useState } from 'react' -import { getSSHConnectionInfo } from '@/api/devbox' +import MyIcon from './Icon' import { useEnvStore } from '@/stores/env' import { IDEType, useIDEStore } from '@/stores/ide' import { DevboxStatusMapType } from '@/types/devbox' -import MyIcon from './Icon' +import { getSSHConnectionInfo } from '@/api/devbox' +import JetBrainsGuideModal from './modals/JetbrainsGuideModal' interface Props { devboxName: string + runtimeType: string sshPort: number status: DevboxStatusMapType isBigButton?: boolean @@ -30,8 +32,21 @@ interface Props { rightButtonProps?: ButtonProps } +export interface JetBrainsGuideData { + devboxName: string + runtimeType: string + privateKey: string + userName: string + token: string + workingDir: string + host: string + port: string + configHost: string +} + const IDEButton = ({ devboxName, + runtimeType, sshPort, status, isBigButton = true, @@ -43,23 +58,46 @@ const IDEButton = ({ const { env } = useEnvStore() const { message: toast } = useMessage() - const [loading, setLoading] = useState(false) const { getDevboxIDEByDevboxName, updateDevboxIDE } = useIDEStore() + + const [loading, setLoading] = useState(false) + const [jetbrainsGuideData, setJetBrainsGuideData] = useState() + const [onOpenJetbrainsModal, setOnOpenJetbrainsModal] = useState(false) const currentIDE = getDevboxIDEByDevboxName(devboxName) as IDEType const handleGotoIDE = useCallback( async (currentIDE: IDEType = 'cursor') => { setLoading(true) - toast({ - title: t('opening_ide'), - status: 'info' - }) + if (currentIDE !== 'jetbrains') { + toast({ + title: t('opening_ide'), + status: 'info' + }) + } try { const { base64PrivateKey, userName, workingDir, token } = await getSSHConnectionInfo({ devboxName }) + const sshPrivateKey = Buffer.from(base64PrivateKey, 'base64').toString('utf-8') + + setJetBrainsGuideData({ + devboxName, + runtimeType, + privateKey: sshPrivateKey, + userName, + token, + workingDir, + host: env.sealosDomain, + port: sshPort.toString(), + configHost: `${env.sealosDomain}_${env.namespace}_${devboxName}` + }) + + if (currentIDE === 'jetbrains') { + setOnOpenJetbrainsModal(true) + return + } const idePrefix = ideObj[currentIDE].prefix const fullUri = `${idePrefix}labring.devbox-aio?sshDomain=${encodeURIComponent( @@ -76,7 +114,7 @@ const IDEButton = ({ setLoading(false) } }, - [devboxName, env.namespace, env.sealosDomain, setLoading, sshPort, toast, t] + [toast, t, devboxName, runtimeType, env.sealosDomain, env.namespace, sshPort] ) return ( @@ -84,7 +122,7 @@ const IDEButton = ({ + + )} + {oneLine && ( + + + + )} + + + {!oneLine && ( + + + + + + )} + + ); +}; + +export default ScriptCode; diff --git a/frontend/providers/devbox/components/Tab.tsx b/frontend/providers/devbox/components/Tab.tsx new file mode 100644 index 00000000000..e8be0f0bb12 --- /dev/null +++ b/frontend/providers/devbox/components/Tab.tsx @@ -0,0 +1,40 @@ +import React from 'react' +import { Box, useTab } from '@chakra-ui/react' + +const Tab = React.forwardRef((props: { children: React.ReactNode }, ref) => { + const tabProps = useTab({ ...props, ref: ref as React.Ref }) + + return ( + + {tabProps.children} + + ) +}) + +Tab.displayName = 'Tab' + +export default Tab diff --git a/frontend/providers/devbox/components/YamlCode/index.module.scss b/frontend/providers/devbox/components/YamlCode/index.module.scss deleted file mode 100644 index 883b64a970c..00000000000 --- a/frontend/providers/devbox/components/YamlCode/index.module.scss +++ /dev/null @@ -1,6 +0,0 @@ -.markdown { - height: '100%'; - div { - overflow: visible !important; - } -} diff --git a/frontend/providers/devbox/components/YamlCode/index.tsx b/frontend/providers/devbox/components/YamlCode/index.tsx deleted file mode 100644 index 7f9ce5cda8f..00000000000 --- a/frontend/providers/devbox/components/YamlCode/index.tsx +++ /dev/null @@ -1,48 +0,0 @@ -import { useMemo } from 'react' -import ReactMarkdown from 'react-markdown' -import SyntaxHighlighter from 'react-syntax-highlighter' - -import { codeTheme } from './hljs' - -import styles from './index.module.scss' - -type TMarkDown = { - content: string - [key: string]: any -} - -const YamlCode = ({ content, ...props }: TMarkDown) => { - const code = useMemo(() => '```yaml\n' + content + '```', [content]) - - return ( - - ) : ( - - {children} - - ) - } - }} - /> - ) -} - -export default YamlCode diff --git a/frontend/providers/devbox/components/modals/JetbrainsGuideModal.tsx b/frontend/providers/devbox/components/modals/JetbrainsGuideModal.tsx new file mode 100644 index 00000000000..e854cf1acd1 --- /dev/null +++ b/frontend/providers/devbox/components/modals/JetbrainsGuideModal.tsx @@ -0,0 +1,527 @@ +import { + Box, + Modal, + ModalBody, + ModalContent, + ModalOverlay, + ModalHeader, + Text, + Divider, + Button, + Flex, + Stepper, + Step, + StepIndicator, + StepStatus, + StepNumber, + StepSeparator, + Grid, + GridItem, + Circle, + ModalCloseButton, + Progress +} from '@chakra-ui/react'; +import { useCallback, useRef, useState } from 'react'; +import { useTranslations } from 'next-intl'; + +import MyIcon from '../Icon'; +import SshConnectModal from './SshConnectModal'; + +import { JetBrainsGuideData } from '../IDEButton'; +import { execCommandInDevboxPod } from '@/api/devbox'; + +const JetBrainsGuideModal = ({ + onClose, + jetbrainsGuideData +}: { + onSuccess: () => void; + onClose: () => void; + jetbrainsGuideData: JetBrainsGuideData; +}) => { + const t = useTranslations(); + + const controllerRef = useRef(null); + + const recommendIDE = runtimeTypeToIDEType(jetbrainsGuideData.runtimeType); + + const [selectedIDE, setSelectedIDE] = useState(recommendIDE); + + const [progress, setProgress] = useState(0); + const [onConnecting, setOnConnecting] = useState(false); + const [onOpenSSHConnectModal, setOnOpenSSHConnectModal] = useState(false); + + const connectIDE = useCallback( + async (idePathName: string, version: string) => { + window.open( + `jetbrains-gateway://connect#host=${ + jetbrainsGuideData.configHost + }&type=ssh&deploy=false&projectPath=${encodeURIComponent( + jetbrainsGuideData.workingDir + )}&user=${encodeURIComponent(jetbrainsGuideData.userName)}&port=${encodeURIComponent( + jetbrainsGuideData.port + )}&idePath=%2Fhome%2Fdevbox%2F.cache%2FJetBrains%2F${idePathName}${version}`, + '_blank' + ); + }, + [jetbrainsGuideData] + ); + + const handleConnectIDE = useCallback(async () => { + const res = await fetch( + `https://data.services.jetbrains.com/products/releases?code=${selectedIDE.productCode}&type=release&latest=true&build=`, + { + method: 'GET', + headers: { + 'Content-Type': 'application/json' + } + } + ); + const data = await res.json(); + + setOnConnecting(true); + + const controller = new AbortController(); + controllerRef.current = controller; + + const version = data[selectedIDE.productCode][0].version; + const downloadLink = data[selectedIDE.productCode][0].downloads['linux']['link']; + const idePathName = selectedIDE.value; + + // NOTE: workingDir /home/devbox/project -> /home/devbox, workingDir maybe change in the future + const basePath = jetbrainsGuideData.workingDir.split('/').slice(0, -1).join('/'); + + const execDownloadCommand = ` + IDE_DIR="${basePath}/.cache/JetBrains/${idePathName}${version}"; + if [ -d "$IDE_DIR" ] && [ ! -f "$IDE_DIR/${selectedIDE.binName}" ]; then + rm -rf "$IDE_DIR"; + fi; + [ ! -d ${basePath}/.cache/JetBrains/${idePathName}${version} ] && mkdir -p ${basePath}/.cache/JetBrains/${idePathName}${version} && wget -q --show-progress --progress=bar:force -O- ${downloadLink} | tar -xzC ${basePath}/.cache/JetBrains/${idePathName}${version} --strip-components=1 && chmod -R 776 ${basePath}/.cache && chown -R devbox:devbox ${basePath}/.cache`; + + try { + await execCommandInDevboxPod({ + devboxName: jetbrainsGuideData.devboxName, + command: execDownloadCommand, + idePath: `/home/devbox/.cache/JetBrains/${idePathName}${version}`, + onDownloadProgress: (progressEvent) => { + const text = progressEvent.event.target.response; + const progressMatch = text.match(/\s+(\d+)%\[/g); + const progress = progressMatch + ? parseInt(progressMatch[progressMatch.length - 1].split('%')[0]) + : null; + + if (progress) { + setProgress(progress); + } + }, + signal: controller.signal + }); + setOnConnecting(false); + setProgress(0); + } catch (error: any) { + if ( + !error || + error.startsWith('nvidia driver modules are not yet loaded, invoking runc directly') || + error.includes('100%') + ) { + connectIDE(idePathName, version); + } + setProgress(0); + setOnConnecting(false); + } + }, [selectedIDE, jetbrainsGuideData.devboxName, connectIDE]); + + return ( + + {} : onClose} + lockFocusAcrossFrames={false} + isCentered + scrollBehavior={'inside'} + > + + + {t('use_jetbrains')} + + + {/* prepare */} + + + {t('jetbrains_guide_prepare')} + + + {/* 1 */} + + + } /> + + + + {t.rich('jetbrains_guide_prepare_install', { + blue: (chunks) => ( + + {chunks} + + ) + })} + + + + + + {/* 2 */} + + + } /> + + + + {t.rich('jetbrains_guide_click_to_config', { + blue: (chunks) => ( + + {chunks} + + ) + })} + + + + + + {/* done */} + + + + + + + + + + {t('jetbrains_guide_start_to_use')} + + + + {t('jetbrains_guide_select_ide')} + + + {Object.values(jetbrainsIDEObj).map((ideType: any) => ( + + { + if (!onConnecting) { + setSelectedIDE(ideType); + } + }} + position={'relative'} + > + + {ideType.label} + {recommendIDE === ideType && ( + + {t('recommend')} + + )} + + + ))} + + + {onConnecting && ( + + + + {t('jetbrains_guide_connecting_info')} + + + )} + + {onOpenSSHConnectModal && ( + setOnOpenSSHConnectModal(false)} + onSuccess={() => {}} + jetbrainsGuideData={jetbrainsGuideData} + /> + )} + + + + + ); +}; + +interface JetbrainsIDEObj { + label: string; + value: string; + binName: string; + productCode: string; +} +const jetbrainsIDEObj: { [key: string]: JetbrainsIDEObj } = { + IntelliJ: { + label: 'IntelliJ IDEA', + value: 'intellij', + binName: 'idea.sh', + productCode: 'IIU' + }, + PyCharm: { + label: 'PyCharm', + value: 'pycharm', + binName: 'pycharm.sh', + productCode: 'PCP' + }, + WebStorm: { + label: 'WebStorm', + value: 'webstorm', + binName: 'webstorm.sh', + productCode: 'WS' + }, + Rider: { + label: 'Rider', + value: 'rider', + binName: 'rider.sh', + productCode: 'RD' + }, + CLion: { + label: 'CLion', + value: 'clion', + binName: 'clion.sh', + productCode: 'CL' + }, + GoLand: { + label: 'GoLand', + value: 'goland', + binName: 'goland.sh', + productCode: 'GO' + }, + RubyMine: { + label: 'RubyMine', + value: 'rubymine', + binName: 'rubymine.sh', + productCode: 'RM' + }, + PhpStorm: { + label: 'PhpStorm', + value: 'phpstorm', + binName: 'phpstorm.sh', + productCode: 'PS' + }, + RustRover: { + label: 'RustRover', + value: 'rustover', + binName: 'rustover.sh', + productCode: 'RR' + } +}; + +const runtimeTypeToIDEType = (runtimeType: string): any => { + switch (runtimeType) { + // Python + case 'python': + case 'django': + case 'flask': + return jetbrainsIDEObj.PyCharm; + + // Go + case 'go': + case 'gin': + case 'echo': + case 'hertz': + case 'iris': + case 'chi': + return jetbrainsIDEObj.GoLand; + + // Frontend and nodejs + case 'angular': + case 'ant-design': + case 'astro': + case 'chakra-ui': + case 'express.js': + case 'react': + case 'vue': + case 'react': + case 'hexo': + case 'hugo': + case 'sealaf': + case 'nuxt3': + case 'svelte': + case 'umi': + case 'vitepress': + case 'next.js': + case 'nest.js': + case 'node.js': + case 'docusaurus': + return jetbrainsIDEObj.WebStorm; + + // C/C++ + case 'c': + case 'cpp': + return jetbrainsIDEObj.CLion; + + // Java + case 'java': + case 'quarkus': + case 'vert.x': + case 'spring-boot': + return jetbrainsIDEObj.IntelliJ; + + // PHP + case 'php': + case 'laravel': + return jetbrainsIDEObj.PhpStorm; + + // Ruby + case 'ruby': + case 'rails': + return jetbrainsIDEObj.RubyMine; + + // Rust + case 'rust': + case 'rocket': + return jetbrainsIDEObj.RustRover; + + // other + case 'debian-ssh': + case 'custom': + default: + return jetbrainsIDEObj.IntelliJ; + } +}; + +export default JetBrainsGuideModal; diff --git a/frontend/providers/devbox/components/modals/releaseModal.tsx b/frontend/providers/devbox/components/modals/ReleaseModal.tsx similarity index 100% rename from frontend/providers/devbox/components/modals/releaseModal.tsx rename to frontend/providers/devbox/components/modals/ReleaseModal.tsx diff --git a/frontend/providers/devbox/components/modals/SshConnectModal.tsx b/frontend/providers/devbox/components/modals/SshConnectModal.tsx new file mode 100644 index 00000000000..8cdd203fe9f --- /dev/null +++ b/frontend/providers/devbox/components/modals/SshConnectModal.tsx @@ -0,0 +1,518 @@ +import { + Box, + Modal, + ModalBody, + ModalContent, + ModalOverlay, + ModalHeader, + ModalCloseButton, + Flex, + Text, + Button, + Divider, + Stepper, + Step, + StepIndicator, + StepNumber, + StepStatus, + StepSeparator, + Circle, + Tabs, + TabList +} from '@chakra-ui/react'; +import { useEffect, useMemo, useState } from 'react'; +import { useTranslations } from 'next-intl'; +import { Tabs as MyTabs } from '@sealos/ui'; + +import Tab from '../Tab'; +import MyIcon from '../Icon'; +import ScriptCode from '../ScriptCode'; +import { + macosAndLinuxScriptsTemplate, + sshConfig, + sshConnectCommand, + windowsScriptsTemplate +} from '@/constants/scripts'; +import { JetBrainsGuideData } from '../IDEButton'; +import { downLoadBlob, useCopyData } from '@/utils/tools'; + +const systemList = ['Windows', 'Mac', 'Linux']; + +enum stepEnum { + OneClick = 'one-click', + StepByStep = 'step-by-step' +} + +const SshConnectModal = ({ + onClose, + jetbrainsGuideData, + onSuccess +}: { + onSuccess: () => void; + onClose: () => void; + jetbrainsGuideData: JetBrainsGuideData; +}) => { + const t = useTranslations(); + const { copyData } = useCopyData(); + + const [activeTab, setActiveTab] = useState(0); + const [activeStep, setActiveStep] = useState(stepEnum.OneClick); + + useEffect(() => { + const detectPlatform = () => { + if (window.navigator.platform) { + const platform = window.navigator.platform.toLowerCase(); + console.log('platform', platform); + if (platform.includes('windows')) return 0; + if (platform.includes('mac')) return 1; + if (platform.includes('linux')) return 2; + } + + const userAgent = window.navigator.userAgent.toLowerCase(); + console.log('userAgent', userAgent); + if (userAgent.includes('win')) return 0; + if (userAgent.includes('mac')) return 1; + if (userAgent.includes('linux')) return 2; + + return 0; + }; + + setActiveTab(detectPlatform()); + }, []); + + const script = useMemo(() => { + if (activeTab === 0) { + return { + platform: 'Windows', + script: windowsScriptsTemplate( + jetbrainsGuideData.privateKey, + jetbrainsGuideData.configHost, + jetbrainsGuideData.host, + jetbrainsGuideData.port, + jetbrainsGuideData.userName + ) + }; + } else if (activeTab === 1) { + return { + platform: 'Mac', + script: macosAndLinuxScriptsTemplate( + jetbrainsGuideData.privateKey, + jetbrainsGuideData.configHost, + jetbrainsGuideData.host, + jetbrainsGuideData.port, + jetbrainsGuideData.userName + ) + }; + } else { + return { + platform: 'Linux', + script: macosAndLinuxScriptsTemplate( + jetbrainsGuideData.privateKey, + jetbrainsGuideData.configHost, + jetbrainsGuideData.host, + jetbrainsGuideData.port, + jetbrainsGuideData.userName + ) + }; + } + }, [activeTab, jetbrainsGuideData]); + + return ( + + + + + {t('jetbrains_guide_config_ssh')} + + + setActiveTab(index)} + mb={4} + colorScheme={'brightBlue'} + defaultIndex={activeTab} + > + + {systemList.map((item) => ( + {item} + ))} + + + + setActiveStep(step as stepEnum)} + /> + {/* one-click */} + {activeStep === stepEnum.OneClick && ( + <> + + {/* 1 */} + + + + } /> + + + { + if (script.platform === 'Windows') { + downLoadBlob( + script.script, + 'text/plain', + `ssh-config-${jetbrainsGuideData.devboxName}.ps1` + ); + } else { + downLoadBlob( + script.script, + 'text/plain', + `ssh-config-${jetbrainsGuideData.devboxName}.sh` + ); + } + }} + > + + + {t('jetbrains_guide_click_to_download')} + + + + {script.platform === 'Windows' + ? t.rich('jetbrains_guide_one_click_setup_desc_windows', { + blue: (chunks) => ( + + {chunks} + + ) + }) + : t.rich('jetbrains_guide_one_click_setup_desc', { + blue: (chunks) => ( + + {chunks} + + ) + })} + + + {t.rich('jetbrains_guide_one_click_setup_desc_2', { + lightColor: (chunks) => ( + + {chunks} + + ) + })} + + + + + + + {/* 2 */} + + + + } /> + + + {t('jetbrains_guide_command')} + + + + + + {/* done */} + + + + + + + + + )} + {/* step-by-step */} + {activeStep === stepEnum.StepByStep && ( + <> + + {/* 1 */} + + + + } /> + + + + {t.rich('jetbrains_guide_download_private_key', { + blue: (chunks) => ( + + {chunks} + + ) + })} + + + + + + + {/* 2 */} + + + + } /> + + + + {t.rich('jetbrains_guide_move_to_path', { + blue: (chunks) => ( + + {chunks} + + ) + })} + + + copyData('~/.ssh/sealos')} + /> + + + + + + {/* 3 */} + + + + } /> + + + + {t.rich('jetbrains_guide_modified_file', { + blue: (chunks) => ( + + {chunks} + + ) + })} + + + + + + + {/* 4 */} + + + + } /> + + + {t('jetbrains_guide_command')} + + + + + + {/* done */} + + + + + + + + + )} + + + + + ); +}; + +export default SshConnectModal; diff --git a/frontend/providers/devbox/components/YamlCode/hljs.ts b/frontend/providers/devbox/constants/hljs.ts similarity index 99% rename from frontend/providers/devbox/components/YamlCode/hljs.ts rename to frontend/providers/devbox/constants/hljs.ts index 796b2e0de82..508ee675466 100644 --- a/frontend/providers/devbox/components/YamlCode/hljs.ts +++ b/frontend/providers/devbox/constants/hljs.ts @@ -118,4 +118,4 @@ export const codeTheme = { '-moz-user-select': 'none' /* Old versions of Firefox */, '-ms-user-select': 'none' /* Internet Explorer/Edge */, 'user-select': 'none' /* Non-prefixed version, currently supported by Chrome, Opera and Firefox */ -}; +} diff --git a/frontend/providers/devbox/constants/scripts.ts b/frontend/providers/devbox/constants/scripts.ts new file mode 100644 index 00000000000..a4fb0cdb72e --- /dev/null +++ b/frontend/providers/devbox/constants/scripts.ts @@ -0,0 +1,181 @@ +export const windowsScriptsTemplate = ( + privateKey: string, + configHost: string, + host: string, + port: string, + user: string +) => `\$ConfigDirTxt = "~/.ssh/sealos/" +\$ConfigDir = "\$HOME\\.ssh\\sealos\\" +\$SSHConfigFile = "\$HOME\\.ssh\\config" + +\$ConfigFileTxt = "~/.ssh/sealos/devbox_config" +\$ConfigFile = "\$ConfigDir\\devbox_config" + +\$PrivateKey = @" +${privateKey} +"@ + +\$Name = "${configHost}" +\$HostName = "${host}" +\$Port = "${port}" +\$User = "${user}" + +\$IdentityFileTxt = "\${ConfigDirTxt}\$Name" +\$IdentityFile = "\$ConfigDir\$Name" +\$HostEntry = "Host \$Name\`n HostName \$HostName\`n Port \$Port\`n User \$User\`n IdentityFile \$IdentityFileTxt\`n IdentitiesOnly yes\`n StrictHostKeyChecking no" + +# Check if the configuration directory exists +if (-Not (Test-Path \$ConfigDir)) { + New-Item -ItemType Directory -Path \$ConfigDir -Force | Out-Null +} + +# Check if the configuration file exists +if (-Not (Test-Path \$ConfigFile)) { + New-Item -ItemType File -Path \$ConfigFile -Force | Out-Null +} + +# Check if the default config exists +if (-Not (Test-Path \$SSHConfigFile)) { + New-Item -ItemType File -Path \$SSHConfigFile -Force | Out-Null +} + +# Check if the .ssh/config file contains the Include statement +if (-Not (Get-Content \$SSHConfigFile)) { + Add-Content -Path \$SSHConfigFile -Value "Include \$ConfigFileTxt\`n" +} else { + if (-Not (Select-String -Path \$SSHConfigFile -Pattern "Include \$ConfigFileTxt")) { + (Get-Content \$SSHConfigFile) | ForEach-Object { + if (\$_ -eq (Get-Content \$SSHConfigFile)[0]) { + "Include \$ConfigFileTxt\`n\$_" + } else { + \$_ + } + } | Set-Content \$SSHConfigFile + } +} + +# Write the private key to the file +\$PrivateKey | Set-Content -Path \$IdentityFile -Force + +# Check if a host with the same name exists +if (Select-String -Path \$ConfigFile -Pattern "^Host \$Name") { + $newContent = @() + $skip = $false + + (Get-Content $ConfigFile) | ForEach-Object { + if ($_ -match "^Host $Name$") { + $skip = $true + $newContent += $HostEntry + } + elseif ($_ -match "^Host ") { + $skip = $false + $newContent += $_ + } + elseif (-not $skip) { + $newContent += $_ + } + } + + $newContent | Set-Content $ConfigFile +} else { + # Append to the end of the file + Add-Content -Path \$ConfigFile -Value \$HostEntry +} +` +export const macosAndLinuxScriptsTemplate = ( + privateKey: string, + configHost: string, + host: string, + port: string, + user: string +) => `#!/bin/bash + +CONFIG_DIR_TXT="~/.ssh/sealos/" +CONFIG_DIR=~/.ssh/sealos/ +SSH_CONFIG_FILE=~/.ssh/config + +CONFIG_FILE_TXT="\${CONFIG_DIR_TXT}devbox_config" +CONFIG_FILE=\${CONFIG_DIR}devbox_config + +PRIVATE_KEY="${privateKey}" +NAME="${configHost}" +HOST="${host}" +PORT="${port}" +USER="${user}" + +IDENTITY_FILE_TXT="\${CONFIG_DIR_TXT}\$NAME" +IDENTITY_FILE="\${CONFIG_DIR}\$NAME" +HOST_ENTRY=" +Host \$NAME + HostName \$HOST + Port \$PORT + User \$USER + IdentityFile \$IDENTITY_FILE_TXT + IdentitiesOnly yes + StrictHostKeyChecking no" + +mkdir -p \$CONFIG_DIR + +if [ ! -f "\$CONFIG_FILE" ]; then + touch "\$CONFIG_FILE" + chmod 0644 "\$CONFIG_FILE" +fi + +if [ ! -f "\$SSH_CONFIG_FILE" ]; then + touch "\$SSH_CONFIG_FILE" + chmod 0600 "\$SSH_CONFIG_FILE" +fi + +if [ ! -s "\$SSH_CONFIG_FILE" ]; then + echo "Include \$CONFIG_FILE_TXT\\n" >> "\$SSH_CONFIG_FILE" +else + if ! grep -q "Include \$CONFIG_FILE_TXT" "\$SSH_CONFIG_FILE"; then + temp_file="\$(mktemp)" + echo "Include \$CONFIG_FILE_TXT" > "\$temp_file" + cat "\$SSH_CONFIG_FILE" >> "\$temp_file" + mv "\$temp_file" "\$SSH_CONFIG_FILE" + fi +fi + +echo "$PRIVATE_KEY" > "$IDENTITY_FILE" +chmod 0600 "$IDENTITY_FILE" + +if grep -q "^Host \$NAME" "\$CONFIG_FILE"; then + temp_file="\$(mktemp)" + awk ' + BEGIN { skip=0 } + /^Host '"$NAME"'$/ { skip=1; next } + /^Host / { + skip=0 + print + next + } + /^$/ { + skip=0 + print + next + } + !skip { print } + ' "$CONFIG_FILE" > "$temp_file" + echo "\$HOST_ENTRY" >> "$temp_file" + mv "$temp_file" "$CONFIG_FILE" +else + echo "\$HOST_ENTRY" >> "\$CONFIG_FILE" +fi` + +export const sshConfig = ( + configHost: string, + host: string, + port: string, + user: string +) => `Host ${configHost} + HostName ${host} + Port ${port} + User ${user} + IdentityFile ~/.ssh/sealos/${configHost} + IdentitiesOnly yes + StrictHostKeyChecking no +` + +export const sshConnectCommand = (configHost: string) => `ssh ${configHost} +` diff --git a/frontend/providers/devbox/message/en.json b/frontend/providers/devbox/message/en.json index 848230c57aa..eef54da39a5 100644 --- a/frontend/providers/devbox/message/en.json +++ b/frontend/providers/devbox/message/en.json @@ -78,6 +78,8 @@ "devbox_name_required": "Devbox name cannot be empty", "devbox_template": "Devbox Templates", "download_config": "Download config", + "download_private_key": "Private Key", + "download_scripts": "Download Script", "edit": "Edit", "edit_failed": "Edit failed", "edit_repository": "Version Manage", @@ -116,6 +118,41 @@ "internal_address": "Internal Address", "invalide_template_name": "Please enter a valid template name", "invalide_template_version": "Please enter a valid version number", + "jetbrains_guide_cancel": "Click to Cancel", + "jetbrains_guide_check_ssh_connection": "Connection Verification", + "jetbrains_guide_click_to_config": "Click the button below to configure SSH connection .", + "jetbrains_guide_click_to_download": "Download the Script", + "jetbrains_guide_command": "Open a terminal and type the following command to verify connection:", + "jetbrains_guide_config_ssh": "SSH Connection Setup", + "jetbrains_guide_confirm": "Setup Complete", + "jetbrains_guide_connecting": "Connecting ({process}%) ...", + "jetbrains_guide_connecting_info": "The initial connection may take 3-5 minutes. Please be patient.", + "jetbrains_guide_documentation": "User Guide", + "jetbrains_guide_download_private_key": "Download the private key.", + "jetbrains_guide_modified_file": "Open ~/.ssh/sealos/devbox_config and append the following content:", + "jetbrains_guide_move_to_path": "Make sure the private key is in the following directory: ~/.ssh/sealos/.", + "jetbrains_guide_one_click_setup": "One-Click Setup", + "jetbrains_guide_one_click_setup_desc": "Click the button to download the bat/sh script, then simply double-click to run it to complete the SSH connection setup.", + "jetbrains_guide_one_click_setup_desc_2": "Alternatively, you can copy the script below, paste it into your local terminal, and run it directly.", + "jetbrains_guide_one_click_setup_desc_windows": "Click the button to download the bat/sh script, then right click the file > Run with PowerShell to complete the SSH connection setup.", + "jetbrains_guide_post_connection": "You can click the button below to view the user guide.", + "jetbrains_guide_post_use": "Learn More", + "jetbrains_guide_prepare": "Prerequiste(s)", + "jetbrains_guide_prepare_install": "You need to download JetBrains Gateway in advance.", + "jetbrains_guide_select_ide": "Choose an IDE to start developing:", + "jetbrains_guide_start_to_connect": "Connect", + "jetbrains_guide_start_to_use": "Getting Started", + "jetbrains_guide_step_1": "In the opened Jetbrains Gateway window, select New Connection.", + "jetbrains_guide_step_2_1": "Fill in UsernameHost and Port.", + "jetbrains_guide_step_2_2": "Download Private Key.", + "jetbrains_guide_step_2_3": "Locate and select the path of the downloaded private key. Click Check Connection and Continue to test the SSH connection.", + "jetbrains_guide_step_3_1": "Select IDE version: It is recommended to choose {ideVersion}.", + "jetbrains_guide_step_3_2": "Select the project path: {projectPath}.", + "jetbrains_guide_step_3_3": "Click Download IDE and Connect to download.", + "jetbrains_guide_step_3_4": "Wait for the IDE to be ready.", + "jetbrains_guide_step_3_5": "Check if local {ide} is automatically invoked.", + "jetbrains_guide_step_by_step": "Step-By-Step", + "jetbrains_guide_three_steps": "(3 steps)", "jump_prompt": "Jump prompt", "jump_terminal_error": "Jump terminal failed", "language": "Language", @@ -136,6 +173,7 @@ "none": "None", "none_template_version": "No versions of this Runtime are currently available, rendering it unusable. To restore functionality, please add a new version in the project details page.", "not_allow_standalone_use": "Not allowed to use standalone", + "one_click_config": "One-Click Setup", "open_link": "Open link", "open_vscode": "VS Code", "opening_ide": "Opening IDE...", @@ -159,6 +197,7 @@ "publish": "Publish", "read_event_detail": "Toggle event detail", "recent_error": "Recent Errors", + "recommend": "Rec", "release": "Release", "release_confirm_info": "During the release process, the machine will be temporarily shut down and the release will be in the current state. Please save the running project.", "release_failed": "Release failed", @@ -226,6 +265,7 @@ "update_template_success": "Template updated successfully!", "update_time": "Last Modified", "use_case": "Use Case", + "use_jetbrains": "Develop with JetBrains IDEs", "used": "Used", "version": "Release", "version_config": "Configuration", diff --git a/frontend/providers/devbox/message/zh.json b/frontend/providers/devbox/message/zh.json index 69838e39c49..c6d697cc139 100644 --- a/frontend/providers/devbox/message/zh.json +++ b/frontend/providers/devbox/message/zh.json @@ -79,6 +79,8 @@ "devbox_name_required": "项目名称不能为空", "devbox_template": "Devbox 模板", "download_config": "下载配置", + "download_private_key": "下载私钥", + "download_scripts": "下载脚本", "edit": "编辑", "edit_failed": "编辑失败", "edit_repository": "版本管理", @@ -118,6 +120,41 @@ "intranet_address": "内网地址", "invalide_template_name": "输入的模板名称不符合规则", "invalide_template_version": "输入的版本号不符合规则", + "jetbrains_guide_cancel": "点击取消", + "jetbrains_guide_check_ssh_connection": "连接验证", + "jetbrains_guide_click_to_config": "点击下方按钮,配置 SSH 连接信息。", + "jetbrains_guide_click_to_download": "点击下载脚本", + "jetbrains_guide_command": "打开命令行,输入以下命令来检测SSH连接是否正常:", + "jetbrains_guide_config_ssh": "配置 SSH 连接信息", + "jetbrains_guide_confirm": "配置完成", + "jetbrains_guide_connecting": "正在连接({process}%)...", + "jetbrains_guide_connecting_info": "第一次连接需要 3-5 min,请耐心等待", + "jetbrains_guide_documentation": "使用文档", + "jetbrains_guide_download_private_key": "下载 私钥。", + "jetbrains_guide_modified_file": "修改 ~/.ssh/sealos/devbox_config 文件(如果不存在需要新建一个文件),在文件末尾追加:", + "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_post_connection": "后续使用可点击下方按钮进行查看。", + "jetbrains_guide_post_use": "后续使用", + "jetbrains_guide_prepare": "前置准备", + "jetbrains_guide_prepare_install": "需要提前下载好 JetBrains Gateway 应用。", + "jetbrains_guide_select_ide": "请选择一个 IDE 进行开发:", + "jetbrains_guide_start_to_connect": "开始连接", + "jetbrains_guide_start_to_use": "开始使用", + "jetbrains_guide_step_1": "在打开的 Jetbrains Gateway 窗口中,选择 New Connection 。", + "jetbrains_guide_step_2_1": "填写 UsernameHostPort。", + "jetbrains_guide_step_2_2": "下载私钥。", + "jetbrains_guide_step_2_3": "勾选 Specify private key,选择刚才下载的私钥的所在路径。点击 Check Connection and Continue 按钮,即可测试 SSH 连接。", + "jetbrains_guide_step_3_1": "选择 IDE 版本:推荐选择 {ideVersion}。", + "jetbrains_guide_step_3_2": "选择项目路径:{projectPath} 。", + "jetbrains_guide_step_3_3": "点击 Download IDE and Connect 按钮,即可下载 IDE 和连接。", + "jetbrains_guide_step_3_4": "等待 IDE 下载完毕。", + "jetbrains_guide_step_3_5": "自动唤起本地的{ide}。", + "jetbrains_guide_step_by_step": "手把手", + "jetbrains_guide_three_steps": "(共 3 步)", "jump_prompt": "跳转提示", "jump_terminal_error": "跳转终端失败", "language": "语言", @@ -138,6 +175,7 @@ "none": "无", "none_template_version": "当前没有可用的模板版本,因此无法继续使用该模板。请在项目详情页中添加新的模板版本以恢复使用。", "not_allow_standalone_use": "不允许独立使用", + "one_click_config": "一键配置", "open_link": "打开链接", "open_vscode": "VS Code", "opening_ide": "正在打开 IDE...", @@ -161,6 +199,7 @@ "publish": "发版", "read_event_detail": "查看事件详情", "recent_error": "最近错误", + "recommend": "推荐", "release": "发布", "release_confirm_info": "发版过程中将暂时关闭机器,且以当前状态发版,请保存好正在运行的项目。", "release_failed": "发版失败", @@ -227,6 +266,7 @@ "update_template_success": "更新模板成功!", "update_time": "更新时间", "use_case": "用例", + "use_jetbrains": "使用 JetBrains IDE 开发", "used": "已用", "version": "版本", "version_config": "版本配置", diff --git a/frontend/providers/devbox/services/backend/kubernetes.ts b/frontend/providers/devbox/services/backend/kubernetes.ts index d7e0f481adc..a615085b3ae 100644 --- a/frontend/providers/devbox/services/backend/kubernetes.ts +++ b/frontend/providers/devbox/services/backend/kubernetes.ts @@ -313,6 +313,7 @@ export async function getK8s({ k8sAuth: kc.makeApiClient(k8s.RbacAuthorizationV1Api), metricsClient: new k8s.Metrics(kc), k8sBatch: kc.makeApiClient(k8s.BatchV1Api), + k8sExec: new k8s.Exec(kc), kube_user, namespace, applyYamlList, diff --git a/frontend/providers/devbox/stores/global.ts b/frontend/providers/devbox/stores/global.ts index 5a3492af779..395e2b2b872 100644 --- a/frontend/providers/devbox/stores/global.ts +++ b/frontend/providers/devbox/stores/global.ts @@ -2,6 +2,8 @@ import { create } from 'zustand' import { devtools } from 'zustand/middleware' import { immer } from 'zustand/middleware/immer' +export type IDEType = 'vscode' | 'cursor' | 'vscodeInsiders' | 'windsurf' | 'jetbrains' + type State = { screenWidth: number setScreenWidth: (e: number) => void diff --git a/frontend/providers/devbox/stores/ide.ts b/frontend/providers/devbox/stores/ide.ts index a12839d38e5..8e1cf178f7a 100644 --- a/frontend/providers/devbox/stores/ide.ts +++ b/frontend/providers/devbox/stores/ide.ts @@ -2,7 +2,7 @@ import { create } from 'zustand' import { devtools, persist } from 'zustand/middleware' import { immer } from 'zustand/middleware/immer' -export type IDEType = 'vscode' | 'cursor' | 'vscodeInsiders' | 'windsurf' +export type IDEType = 'vscode' | 'cursor' | 'vscodeInsiders' | 'windsurf' | 'jetbrains' type State = { devboxIDEList: { ide: IDEType; devboxName: string }[] diff --git a/frontend/providers/devbox/types/devbox.d.ts b/frontend/providers/devbox/types/devbox.d.ts index ea9b3b44783..8eac8176d0f 100644 --- a/frontend/providers/devbox/types/devbox.d.ts +++ b/frontend/providers/devbox/types/devbox.d.ts @@ -109,7 +109,7 @@ export interface DevboxDetailTypeV2 extends json2DevboxV2Data { sshDomain: string sshPort: number sshPrivateKey: string - }, + } sshPort?: number lastTerminatedReason?: string } @@ -143,9 +143,9 @@ export interface DevboxListItemTypeV2 { // templateRepository: object template: { templateRepository: { - iconId: string | null; - }; - uid: string; + iconId: string | null + } + uid: string } status: DevboxStatusMapType createTime: string @@ -224,7 +224,6 @@ export interface PodDetailType extends V1Pod { } export interface json2DevboxV2Data extends DevboxEditTypeV2 { - templateConfig: string, - image: string, + templateConfig: string + image: string } - diff --git a/frontend/providers/devbox/utils/tools.ts b/frontend/providers/devbox/utils/tools.ts index 22a468479b7..a36c2351779 100644 --- a/frontend/providers/devbox/utils/tools.ts +++ b/frontend/providers/devbox/utils/tools.ts @@ -91,25 +91,38 @@ export const useCopyData = () => { const t = useTranslations() return { - copyData: (data: string, title: string = 'copy_success') => { + copyData: async (data: string, title: string = 'copy_success') => { try { - const textarea = document.createElement('textarea') - textarea.value = data - document.body.appendChild(textarea) - textarea.select() - document.execCommand('copy') - document.body.removeChild(textarea) + await navigator.clipboard.writeText(data) + toast({ title: t(title), status: 'success', duration: 1000 }) } catch (error) { - console.error(error) - toast({ - title: t('copy_failed'), - status: 'error' - }) + try { + const textarea = document.createElement('textarea') + textarea.value = data + textarea.style.position = 'fixed' + textarea.style.opacity = '0' + document.body.appendChild(textarea) + textarea.select() + document.execCommand('copy') + document.body.removeChild(textarea) + + toast({ + title: t(title), + status: 'success', + duration: 1000 + }) + } catch (fallbackError) { + console.error('Copy failed:', fallbackError) + toast({ + title: t('copy_failed'), + status: 'error' + }) + } } } }