Skip to content

Commit e9a302b

Browse files
authored
feat(devbox): nfs,env,configmap support (#6501)
* feat: basic ui * feat: basic logic * feat: nfs logic * fix: conflict bug * fix: configmap id bug * feat: update yaml * fix: update devbox bug * feat: delete devbox need to delete cm and pvc * feat: detail support advanced config * chore: update env * feat: configmap and nfs input path validate * feat: env duplicated key validate * feat: nfs 20g max * chore: configmap i18n adjust * feat: backend path validation * feat: env to control advanced config * feat: deploy to applaunchpad * feat: nfs input * fix: default from 1 to 0 * fix: add storageClassName: 'nfs-csi' * fix: storageClassName bug
1 parent 690033f commit e9a302b

35 files changed

Lines changed: 1741 additions & 130 deletions

File tree

frontend/providers/devbox/.env.template

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,8 @@ GPU_ENABLE= "false"
6464
ENABLE_IMPORT_FEATURE="false"
6565
# enable web IDE feature, default is false
6666
ENABLE_WEBIDE_FEATURE="false"
67+
# enable advanced config feature(env, configmap, nfs), default is false
68+
ENABLE_ADVANCED_CONFIG="false"
6769

6870
# CPU and Memory slider configuration (comma-separated numbers)
6971
CPU_SLIDE_MARK_LIST='1,2,4,8,16'
@@ -73,6 +75,8 @@ MEMORY_SLIDE_MARK_LIST='2,4,8,16,32'
7375
# Change this secret in production for security
7476
DEVBOX_DOMAIN_CHALLENGE_SECRET='default-dev-secret-change-in-production'
7577

78+
# NFS storage class name for template store and user data, default is 'nfs-csi',every cluster should have their own storage class
79+
NFS_STORAGE_CLASS_NAME='nfs-csi'
7680

7781

7882

Lines changed: 260 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,260 @@
1+
'use client';
2+
3+
import { useState } from 'react';
4+
import { useTranslations } from 'next-intl';
5+
import { useFormContext } from 'react-hook-form';
6+
import { PencilLine, Plus, Trash2, FileText, HardDrive } from 'lucide-react';
7+
8+
import { Button } from '@sealos/shadcn-ui/button';
9+
import { Separator } from '@sealos/shadcn-ui/separator';
10+
import EnvVariablesDrawer from '@/components/drawers/EnvVariablesDrawer';
11+
import ConfigMapDrawer from '@/components/drawers/ConfigMapDrawer';
12+
import NetworkStorageDrawer from '@/components/drawers/NetworkStorageDrawer';
13+
import type { DevboxEditTypeV2 } from '@/types/devbox';
14+
15+
export default function AdvancedConfig() {
16+
const t = useTranslations();
17+
const { watch, setValue } = useFormContext<DevboxEditTypeV2>();
18+
19+
const envs = watch('envs') || [];
20+
const configMaps = watch('configMaps') || [];
21+
const volumes = watch('volumes') || [];
22+
23+
const [isEnvDrawerOpen, setIsEnvDrawerOpen] = useState(false);
24+
const [isConfigMapDrawerOpen, setIsConfigMapDrawerOpen] = useState(false);
25+
const [editingConfigMapIndex, setEditingConfigMapIndex] = useState<number | null>(null);
26+
const [isNetworkStorageDrawerOpen, setIsNetworkStorageDrawerOpen] = useState(false);
27+
const [editingStorageIndex, setEditingStorageIndex] = useState<number | null>(null);
28+
29+
return (
30+
<div className="flex flex-col gap-6 rounded-2xl border border-zinc-200 bg-white p-8">
31+
{/* Header */}
32+
<div className="flex items-center gap-2">
33+
<span className="text-xl/7 font-medium">{t('advanced_configurations')}</span>
34+
<div className="flex items-center rounded-full border border-zinc-200 bg-zinc-50 px-2 py-1.5">
35+
<span className="text-xs/4 font-medium text-zinc-600">{t('optional')}</span>
36+
</div>
37+
</div>
38+
39+
{/* Environment Variables */}
40+
<div id="env" className="flex flex-col gap-3">
41+
<div className="flex items-center justify-between">
42+
<span className="text-base font-medium">{t('environment_variables')}</span>
43+
<Button
44+
variant="outline"
45+
className="h-9 gap-2 bg-white px-4 py-2"
46+
onClick={() => setIsEnvDrawerOpen(true)}
47+
>
48+
<PencilLine className="h-4 w-4 text-neutral-500" />
49+
<span className="text-sm/5 font-medium">{t('edit')}</span>
50+
</Button>
51+
</div>
52+
53+
{/* Env Variables Table */}
54+
{envs.length > 0 && (
55+
<div className="overflow-hidden rounded-lg border border-zinc-200">
56+
{/* Table Header */}
57+
<div className="flex border-b border-zinc-200 bg-zinc-50">
58+
<div className="w-50 border-r border-zinc-200 px-3 py-2">
59+
<span className="text-sm font-semibold text-zinc-500">{t('key')}</span>
60+
</div>
61+
<div className="flex-1 px-3 py-2">
62+
<span className="text-sm font-semibold text-zinc-500">{t('value')}</span>
63+
</div>
64+
</div>
65+
66+
{/* Table Body */}
67+
{envs.map((env, idx) => (
68+
<div
69+
key={idx}
70+
className={`flex border-b border-zinc-200 last:border-b-0 ${
71+
idx % 2 === 1 ? 'bg-zinc-50' : ''
72+
}`}
73+
>
74+
<div className="w-50 border-r border-zinc-200 px-3 py-2">
75+
<span className="truncate text-sm">{env.key}</span>
76+
</div>
77+
<div className="flex-1 px-3 py-2">
78+
<span className="truncate text-sm">{env.value}</span>
79+
</div>
80+
</div>
81+
))}
82+
</div>
83+
)}
84+
</div>
85+
86+
<Separator className="my-1" />
87+
88+
{/* ConfigMaps */}
89+
<div id="configmap" className="flex flex-col gap-3">
90+
<div className="flex items-center justify-between">
91+
<span className="text-base font-medium">{t('configmaps')}</span>
92+
<Button
93+
variant="outline"
94+
className="h-9 gap-2 bg-white px-4 py-2"
95+
onClick={() => {
96+
setEditingConfigMapIndex(null);
97+
setIsConfigMapDrawerOpen(true);
98+
}}
99+
>
100+
<Plus className="h-4 w-4 text-neutral-500" />
101+
<span className="text-sm/5 font-medium">{t('add')}</span>
102+
</Button>
103+
</div>
104+
105+
{/* ConfigMaps List */}
106+
<div className="flex flex-col gap-1">
107+
{configMaps.map((config, idx) => (
108+
<div
109+
key={idx}
110+
className="flex h-14 items-center justify-between rounded-lg border border-zinc-100 bg-zinc-50 px-3 py-3"
111+
>
112+
<div
113+
className="flex flex-1 cursor-pointer items-center gap-3"
114+
onClick={() => {
115+
setEditingConfigMapIndex(idx);
116+
setIsConfigMapDrawerOpen(true);
117+
}}
118+
>
119+
<FileText className="h-6 w-6 text-zinc-400" />
120+
<div className="flex flex-col gap-1">
121+
<span className="text-sm font-medium text-gray-900">{config.path}</span>
122+
<span className="text-xs text-neutral-500">
123+
{config.content.slice(0, 50)}
124+
{config.content.length > 50 ? '...' : ''}
125+
</span>
126+
</div>
127+
</div>
128+
<Button
129+
variant="ghost"
130+
size="icon"
131+
className="h-4 w-4 p-0 text-neutral-500 hover:bg-transparent hover:text-red-600"
132+
onClick={() => {
133+
setValue(
134+
'configMaps',
135+
configMaps.filter((_, i) => i !== idx)
136+
);
137+
}}
138+
>
139+
<Trash2 className="h-4 w-4" />
140+
</Button>
141+
</div>
142+
))}
143+
</div>
144+
</div>
145+
146+
<Separator className="my-1" />
147+
148+
{/* Network Storage */}
149+
<div id="storage" className="flex flex-col gap-3">
150+
<div className="flex items-center justify-between">
151+
<span className="text-base font-medium">{t('network_storage')}</span>
152+
<Button
153+
variant="outline"
154+
className="h-9 gap-2 bg-white px-4 py-2"
155+
onClick={() => {
156+
setEditingStorageIndex(null);
157+
setIsNetworkStorageDrawerOpen(true);
158+
}}
159+
>
160+
<Plus className="h-4 w-4 text-neutral-500" />
161+
<span className="text-sm/5 font-medium">{t('add')}</span>
162+
</Button>
163+
</div>
164+
165+
{/* Storage List */}
166+
<div className="flex flex-col gap-1">
167+
{volumes.map((storage, idx) => (
168+
<div
169+
key={idx}
170+
className="flex h-14 items-center justify-between rounded-lg border border-zinc-100 bg-zinc-50 px-3 py-3"
171+
>
172+
<div
173+
className="flex flex-1 cursor-pointer items-center gap-3"
174+
onClick={() => {
175+
setEditingStorageIndex(idx);
176+
setIsNetworkStorageDrawerOpen(true);
177+
}}
178+
>
179+
<HardDrive className="h-6 w-6 text-zinc-400" />
180+
<div className="flex flex-col gap-1">
181+
<span className="text-sm font-medium text-gray-900">{storage.path}</span>
182+
<span className="text-xs text-neutral-500">{storage.size} Gi</span>
183+
</div>
184+
</div>
185+
<Button
186+
variant="ghost"
187+
size="icon"
188+
className="h-4 w-4 p-0 text-neutral-500 hover:bg-transparent hover:text-red-600"
189+
onClick={() => {
190+
setValue(
191+
'volumes',
192+
volumes.filter((_, i) => i !== idx)
193+
);
194+
}}
195+
>
196+
<Trash2 className="h-4 w-4" />
197+
</Button>
198+
</div>
199+
))}
200+
</div>
201+
</div>
202+
203+
{isEnvDrawerOpen && (
204+
<EnvVariablesDrawer
205+
initialValue={envs}
206+
onClose={() => setIsEnvDrawerOpen(false)}
207+
onSuccess={(newEnvVars) => {
208+
setValue('envs', newEnvVars);
209+
}}
210+
/>
211+
)}
212+
213+
{isConfigMapDrawerOpen && (
214+
<ConfigMapDrawer
215+
initialValue={
216+
editingConfigMapIndex !== null ? configMaps[editingConfigMapIndex] : undefined
217+
}
218+
existingPaths={configMaps
219+
.filter((_, idx) => idx !== editingConfigMapIndex)
220+
.map((item) => item.path.toLowerCase())}
221+
onClose={() => {
222+
setIsConfigMapDrawerOpen(false);
223+
setEditingConfigMapIndex(null);
224+
}}
225+
onSuccess={(newConfigMap) => {
226+
if (editingConfigMapIndex !== null) {
227+
const updated = [...configMaps];
228+
updated[editingConfigMapIndex] = newConfigMap;
229+
setValue('configMaps', updated);
230+
} else {
231+
setValue('configMaps', [...configMaps, newConfigMap]);
232+
}
233+
}}
234+
/>
235+
)}
236+
237+
{isNetworkStorageDrawerOpen && (
238+
<NetworkStorageDrawer
239+
initialValue={editingStorageIndex !== null ? volumes[editingStorageIndex] : undefined}
240+
existingPaths={volumes
241+
.filter((_, idx) => idx !== editingStorageIndex)
242+
.map((item) => item.path.toLowerCase())}
243+
onClose={() => {
244+
setIsNetworkStorageDrawerOpen(false);
245+
setEditingStorageIndex(null);
246+
}}
247+
onSuccess={(newStorage) => {
248+
if (editingStorageIndex !== null) {
249+
const updated = [...volumes];
250+
updated[editingStorageIndex] = newStorage;
251+
setValue('volumes', updated);
252+
} else {
253+
setValue('volumes', [...volumes, newStorage]);
254+
}
255+
}}
256+
/>
257+
)}
258+
</div>
259+
);
260+
}

frontend/providers/devbox/app/[lang]/(platform)/devbox/create/components/Form.tsx

Lines changed: 15 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,6 @@ import { useSearchParams } from 'next/navigation';
77

88
import { useRouter } from '@/i18n';
99
import { obj2Query } from '@/utils/tools';
10-
import { useDevboxStore } from '@/stores/devbox';
1110
import type { DevboxEditTypeV2 } from '@/types/devbox';
1211

1312
import Gpu from './Gpu';
@@ -18,8 +17,10 @@ import Runtime from './Runtime';
1817
import PriceBox from './PriceBox';
1918
import QuotaBox from './QuotaBox';
2019
import DevboxName from './DevboxName';
20+
import AdvancedConfig from './AdvancedConfig';
2121

2222
import { Tabs, TabsList, TabsTrigger } from '@sealos/shadcn-ui/tabs';
23+
import { useEnvStore } from '@/stores/env';
2324

2425
interface FormProps {
2526
isEdit: boolean;
@@ -31,15 +32,20 @@ const Form = ({ isEdit, countGpuInventory }: FormProps) => {
3132
const searchParams = useSearchParams();
3233
const t = useTranslations();
3334
const { watch } = useFormContext<DevboxEditTypeV2>();
35+
const { env } = useEnvStore();
3436

3537
const formValues = watch();
38+
const showAdvancedConfig = env.enableAdvancedConfig === 'true';
3639

3740
useEffect(() => {
38-
if (searchParams.get('scrollTo') === 'network') {
39-
const el = document.getElementById('network');
40-
if (el) {
41-
el.scrollIntoView({ behavior: 'smooth', block: 'center' });
42-
}
41+
const scrollTo = searchParams.get('scrollTo');
42+
if (scrollTo) {
43+
setTimeout(() => {
44+
const el = document.getElementById(scrollTo);
45+
if (el) {
46+
el.scrollIntoView({ behavior: 'smooth', block: 'center' });
47+
}
48+
}, 500);
4349
}
4450
}, [searchParams]);
4551

@@ -91,6 +97,9 @@ const Form = ({ isEdit, countGpuInventory }: FormProps) => {
9197
<div id="network">
9298
<Network isEdit={isEdit} />
9399
</div>
100+
101+
{/* Advanced Configurations */}
102+
{showAdvancedConfig && <AdvancedConfig />}
94103
</div>
95104
</div>
96105
);

frontend/providers/devbox/app/[lang]/(platform)/devbox/create/components/Header.tsx

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -44,16 +44,18 @@ const Header = ({ title, yamlList, applyCb, applyBtnText }: HeaderProps) => {
4444
const isClientSide = useClientSideValue(true);
4545

4646
const handleBack = useCallback(() => {
47+
const fromTab = searchParams.get('fromTab');
4748
if (name) {
4849
if (from === 'detail') {
49-
router.replace(`/devbox/detail/${name}`);
50+
const url = fromTab ? `/devbox/detail/${name}?tab=${fromTab}` : `/devbox/detail/${name}`;
51+
router.replace(url);
5052
} else if (from === 'list') {
5153
router.replace(`/`);
5254
}
5355
} else {
5456
router.push('/template');
5557
}
56-
}, [name, router, from]);
58+
}, [name, router, from, searchParams]);
5759

5860
return (
5961
<>

frontend/providers/devbox/app/[lang]/(platform)/devbox/create/page.tsx

Lines changed: 11 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -67,21 +67,17 @@ const DevboxCreatePage = () => {
6767
const name = searchParams.get('name');
6868
const from = searchParams.get('from');
6969
const scrollTo = searchParams.get('scrollTo');
70-
if (name) {
70+
if (name && name !== captureDevboxName) {
7171
setCaptureDevboxName(name);
72-
router.replace(`/devbox/create?name=${captureDevboxName}`, undefined);
73-
if (from) {
74-
setCaptureFrom(from);
75-
router.replace(`/devbox/create?name=${captureDevboxName}&from=${captureFrom}`, undefined);
76-
if (scrollTo) {
77-
setCaptureScrollTo(scrollTo);
78-
router.replace(
79-
`/devbox/create?name=${captureDevboxName}&scrollTo=${captureScrollTo}`,
80-
undefined
81-
);
82-
}
83-
}
84-
} else if (from === 'template') {
72+
}
73+
if (from && from !== captureFrom) {
74+
setCaptureFrom(from);
75+
}
76+
if (scrollTo && scrollTo !== captureScrollTo) {
77+
setCaptureScrollTo(scrollTo);
78+
}
79+
80+
if (from === 'template' && !name) {
8581
const savedFormData = localStorage.getItem('devbox_create_form_data');
8682
if (savedFormData) {
8783
try {
@@ -93,7 +89,7 @@ const DevboxCreatePage = () => {
9389
}
9490
}
9591
}
96-
}, [searchParams, router, captureDevboxName, captureScrollTo, captureFrom, formHook]);
92+
}, [searchParams, captureDevboxName, captureScrollTo, captureFrom, formHook]);
9793

9894
// eslint-disable-next-line react-hooks/exhaustive-deps
9995
const isEdit = useMemo(() => !!devboxName, []);

0 commit comments

Comments
 (0)