Skip to content

Commit 49d307d

Browse files
authored
Merge pull request #3841 from headlamp-k8s/improve-project-from-yaml
Improve project from yaml
2 parents 6993287 + 130c2bc commit 49d307d

File tree

14 files changed

+533
-113
lines changed

14 files changed

+533
-113
lines changed

frontend/src/components/project/ProjectCreateFromYaml.tsx

Lines changed: 215 additions & 66 deletions
Original file line numberDiff line numberDiff line change
@@ -14,21 +14,26 @@
1414
* limitations under the License.
1515
*/
1616

17-
import { Icon } from '@iconify/react';
17+
import { Icon, InlineIcon } from '@iconify/react';
1818
import {
1919
Autocomplete,
2020
Box,
2121
Button,
2222
CircularProgress,
2323
DialogActions,
2424
DialogContent,
25+
FormControl,
2526
Grid,
26-
Input,
27+
Tab,
28+
Tabs,
2729
TextField,
30+
Tooltip,
2831
Typography,
2932
} from '@mui/material';
33+
import { styled } from '@mui/system';
3034
import { loadAll } from 'js-yaml';
3135
import { Dispatch, FormEvent, SetStateAction, useState } from 'react';
36+
import { useDropzone } from 'react-dropzone';
3237
import { Trans, useTranslation } from 'react-i18next';
3338
import { Redirect, useHistory } from 'react-router';
3439
import { useClustersConf } from '../../lib/k8s';
@@ -38,7 +43,26 @@ import { createRouteURL } from '../../lib/router';
3843
import { ViewYaml } from '../advancedSearch/ResourceSearch';
3944
import Table from '../common/Table';
4045
import { KubeIcon } from '../resourceMap/kubeIcon/KubeIcon';
41-
import { toKubernetesName } from './projectUtils';
46+
import { PROJECT_ID_LABEL, toKubernetesName } from './projectUtils';
47+
48+
const DropZoneBox = styled(Box)({
49+
border: 1,
50+
borderRadius: 1,
51+
borderWidth: 2,
52+
borderColor: 'rgba(0, 0, 0)',
53+
borderStyle: 'dashed',
54+
padding: '20px',
55+
margin: '20px',
56+
display: 'flex',
57+
flexDirection: 'column',
58+
alignItems: 'center',
59+
'&:hover': {
60+
borderColor: 'rgba(0, 0, 0, 0.5)',
61+
},
62+
'&:focus-within': {
63+
borderColor: 'rgba(0, 0, 0, 0.5)',
64+
},
65+
});
4266

4367
async function createProjectFromYaml({
4468
items,
@@ -63,7 +87,7 @@ async function createProjectFromYaml({
6387
metadata: {
6488
name: k8sName,
6589
labels: {
66-
PROJECT_ID_LABEL: k8sName,
90+
[PROJECT_ID_LABEL]: k8sName,
6791
},
6892
} as any,
6993
} as any;
@@ -117,6 +141,96 @@ export function CreateNew() {
117141

118142
const [errors, setErrors] = useState<Record<string, string>>({});
119143

144+
// New state for URL and tab management
145+
const [currentTab, setCurrentTab] = useState(0);
146+
const [yamlUrl, setYamlUrl] = useState('');
147+
const [isLoadingFromUrl, setIsLoadingFromUrl] = useState(false);
148+
149+
// Function to load YAML from URL
150+
const loadFromUrl = async () => {
151+
if (!yamlUrl.trim()) {
152+
setErrors(prev => ({ ...prev, url: t('URL is required') }));
153+
return;
154+
}
155+
156+
setIsLoadingFromUrl(true);
157+
setErrors(prev => ({ ...prev, url: '' }));
158+
159+
try {
160+
const response = await fetch(yamlUrl);
161+
if (!response.ok) {
162+
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
163+
}
164+
const content = await response.text();
165+
const docs = loadAll(content) as KubeObjectInterface[];
166+
const validDocs = docs.filter(doc => !!doc);
167+
setItems(validDocs);
168+
setErrors(prev => ({ ...prev, items: '' }));
169+
} catch (error) {
170+
setErrors(prev => ({
171+
...prev,
172+
url: t('Failed to load from URL: {{error}}', {
173+
error: (error as Error).message,
174+
}),
175+
}));
176+
} finally {
177+
setIsLoadingFromUrl(false);
178+
}
179+
};
180+
181+
// File drop functionality
182+
const onDrop = (acceptedFiles: File[]) => {
183+
setErrors(prev => ({ ...prev, items: '' }));
184+
185+
const promises = acceptedFiles.map(file => {
186+
return new Promise<{ docs: KubeObjectInterface[] }>((resolve, reject) => {
187+
const reader = new FileReader();
188+
reader.onload = () => {
189+
const content = reader.result as string;
190+
try {
191+
const docs = loadAll(content) as KubeObjectInterface[];
192+
const validDocs = docs.filter(doc => !!doc);
193+
resolve({ docs: validDocs });
194+
} catch (err) {
195+
console.error('Error parsing YAML file:', file.name, err);
196+
// Resolve with empty array for failed files
197+
resolve({ docs: [] });
198+
}
199+
};
200+
reader.onerror = err => {
201+
console.error('Error reading file:', file.name, err);
202+
reject(err);
203+
};
204+
reader.readAsText(file);
205+
});
206+
});
207+
208+
Promise.all(promises)
209+
.then(results => {
210+
const newDocs = results.flatMap(result => result.docs);
211+
setItems(prevItems => [...prevItems, ...newDocs]);
212+
})
213+
.catch(err => {
214+
console.error('An error occurred while processing files.', err);
215+
setErrors(prev => ({
216+
...prev,
217+
items: t('Error processing files: {{error}}', {
218+
error: (err as Error).message,
219+
}),
220+
}));
221+
});
222+
};
223+
224+
const { getRootProps, getInputProps, open } = useDropzone({
225+
onDrop,
226+
accept: {
227+
'application/x-yaml': ['.yaml', '.yml'],
228+
'text/yaml': ['.yaml', '.yml'],
229+
'text/plain': ['.yaml', '.yml'],
230+
},
231+
multiple: true,
232+
});
233+
120234
const handleCreate = async (e: FormEvent) => {
121235
e.preventDefault();
122236

@@ -131,7 +245,6 @@ export function CreateNew() {
131245
errors.items = t('No resources have been uploaded');
132246
}
133247

134-
console.log(errors);
135248
if (Object.keys(errors).length > 0) {
136249
setErrors(errors);
137250
return;
@@ -219,81 +332,109 @@ export function CreateNew() {
219332
</Grid>
220333

221334
<Grid item xs={3}>
222-
<Typography>{t('Upload files(s)')}</Typography>
335+
<Typography>{t('Load resources')}</Typography>
223336
<Typography variant="body2" color="text.secondary" sx={{ mb: 1 }}>
224-
{t('Upload your YAML file(s)')}
337+
{t('Upload files or load from URL')}
225338
</Typography>
226339
</Grid>
227340
<Grid item xs={9}>
228341
{errors.items && <Typography color="error">{errors.items}</Typography>}
229342

230-
<Input
231-
type="file"
232-
error={!!errors.items}
233-
sx={theme => ({
234-
'::before,::after': {
235-
display: 'none',
236-
},
237-
p: 1,
238-
background: theme.palette.background.muted,
239-
border: '1px solid',
240-
borderColor: theme.palette.divider,
241-
borderRadius: theme.shape.borderRadius + 'px',
242-
mt: 0,
243-
})}
244-
inputProps={{
245-
accept: '.yaml,.yml,applicaiton/yaml',
246-
multiple: true,
247-
}}
248-
onChange={e => {
249-
const fileList = (e.target as HTMLInputElement).files;
250-
if (!fileList) return;
251-
252-
const promises = Array.from(fileList).map(file => {
253-
return new Promise<{ docs: KubeObjectInterface[] }>((resolve, reject) => {
254-
const reader = new FileReader();
255-
reader.onload = () => {
256-
const content = reader.result as string;
257-
try {
258-
const docs = loadAll(content) as KubeObjectInterface[];
259-
resolve({ docs });
260-
} catch (err) {
261-
console.error('Error parsing YAML file:', file.name, err);
262-
// Optionally, you can decide how to handle parsing errors.
263-
// Here we resolve with an empty array for the failed file.
264-
resolve({ docs: [] });
343+
<Box sx={{ width: '100%' }}>
344+
<Box sx={{ borderBottom: 1, borderColor: 'divider' }}>
345+
<Tabs value={currentTab} onChange={(_, newValue) => setCurrentTab(newValue)}>
346+
<Tab label={t('Upload Files')} />
347+
<Tab label={t('Load from URL')} />
348+
</Tabs>
349+
</Box>
350+
351+
{/* File Upload Tab */}
352+
{currentTab === 0 && (
353+
<Box sx={{ pt: 2 }}>
354+
<DropZoneBox border={1} borderColor="secondary.main" {...getRootProps()}>
355+
<FormControl>
356+
<input {...getInputProps()} />
357+
<Tooltip
358+
title={t('Drag & drop YAML files here or click to choose files')}
359+
placement="top"
360+
>
361+
<Button
362+
variant="contained"
363+
onClick={open}
364+
startIcon={<InlineIcon icon="mdi:upload" width={24} />}
365+
>
366+
{t('Choose Files')}
367+
</Button>
368+
</Tooltip>
369+
</FormControl>
370+
<Typography variant="body2" color="text.secondary" sx={{ mt: 1 }}>
371+
{t('Supports .yaml and .yml files')}
372+
</Typography>
373+
</DropZoneBox>
374+
</Box>
375+
)}
376+
377+
{/* URL Loading Tab */}
378+
{currentTab === 1 && (
379+
<Box sx={{ pt: 2 }}>
380+
<Box sx={{ display: 'flex', gap: 2, alignItems: 'flex-start' }}>
381+
<TextField
382+
fullWidth
383+
label={t('YAML URL')}
384+
placeholder={t('Enter URL to YAML file')}
385+
variant="outlined"
386+
size="small"
387+
value={yamlUrl}
388+
onChange={e => setYamlUrl(e.target.value)}
389+
error={!!errors.url}
390+
helperText={errors.url}
391+
disabled={isLoadingFromUrl}
392+
/>
393+
<Button
394+
variant="contained"
395+
onClick={loadFromUrl}
396+
disabled={isLoadingFromUrl || !yamlUrl.trim()}
397+
startIcon={
398+
isLoadingFromUrl ? (
399+
<CircularProgress size={16} />
400+
) : (
401+
<InlineIcon icon="mdi:download" width={24} />
402+
)
265403
}
266-
};
267-
reader.onerror = err => {
268-
console.error('Error reading file:', file.name, err);
269-
reject(err);
270-
};
271-
reader.readAsText(file);
272-
});
273-
});
274-
275-
Promise.all(promises)
276-
.then(results => {
277-
const newDocs = results.flatMap(result => result.docs);
278-
setItems(newDocs);
279-
// setYamlDocs(currentDocs => [...currentDocs, ...newDocs]);
280-
})
281-
.catch(err => {
282-
console.error('An error occurred while processing files.', err);
283-
});
284-
}}
285-
/>
404+
>
405+
{isLoadingFromUrl ? t('Loading...') : t('Load')}
406+
</Button>
407+
</Box>
408+
<Typography variant="body2" color="text.secondary" sx={{ mt: 1 }}>
409+
{t('Load YAML resources from a remote URL')}
410+
</Typography>
411+
</Box>
412+
)}
413+
</Box>
286414
</Grid>
287415
</Grid>
288416

289417
{items.length > 0 && (
290418
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3, mt: 3 }}>
291-
<Typography>{t('Items')}</Typography>
419+
<Box
420+
sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}
421+
>
422+
<Typography>
423+
{t('Loaded Resources ({{count}})', { count: items.length })}
424+
</Typography>
425+
<Button
426+
variant="outlined"
427+
size="small"
428+
onClick={() => setItems([])}
429+
startIcon={<InlineIcon icon="mdi:delete" width={16} />}
430+
>
431+
{t('Clear All')}
432+
</Button>
433+
</Box>
292434
<Box
293435
sx={{
294436
display: 'flex',
295437
flexDirection: 'column',
296-
marginTop: items.length > 0 ? '-46px' : 0,
297438
}}
298439
>
299440
<Table
@@ -318,6 +459,11 @@ export function CreateNew() {
318459
header: t('Name'),
319460
accessorFn: item => item.metadata.name,
320461
},
462+
{
463+
id: 'apiVersion',
464+
header: t('API Version'),
465+
accessorFn: item => item.apiVersion,
466+
},
321467
{
322468
id: 'actions',
323469
header: t('Actions'),
@@ -357,8 +503,11 @@ export function CreateNew() {
357503
{t('Creating following resources in this project:')}
358504
</Typography>
359505
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1, mt: 1 }}>
360-
{creationState.createdResources.map(resource => (
361-
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
506+
{creationState.createdResources.map((resource, index) => (
507+
<Box
508+
key={`created-${resource.kind}-${resource.metadata.name}-${index}`}
509+
sx={{ display: 'flex', alignItems: 'center', gap: 1 }}
510+
>
362511
<KubeIcon kind={resource.kind as any} width="24px" height="24px" />
363512
<Box>{resource.metadata.name}</Box>
364513
<Box sx={theme => ({ color: theme.palette.success.main })}>

0 commit comments

Comments
 (0)