UNPKG

@kinvolk/headlamp-plugin

Version:

The needed infrastructure for building Headlamp plugins.

259 lines (258 loc) 17 kB
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime"; /* * Copyright 2025 The Kubernetes Authors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ import { Icon, InlineIcon } from '@iconify/react'; import { Autocomplete, Box, Button, CircularProgress, DialogActions, DialogContent, FormControl, Grid, Tab, Tabs, TextField, Tooltip, Typography, } from '@mui/material'; import { loadAll } from 'js-yaml'; import { useMemo, useState } from 'react'; import { useDropzone } from 'react-dropzone'; import { Trans, useTranslation } from 'react-i18next'; import { Redirect, useHistory } from 'react-router'; import { useClustersConf } from '../../lib/k8s'; import { apply } from '../../lib/k8s/api/v1/apply'; import Namespace from '../../lib/k8s/namespace'; import { createRouteURL } from '../../lib/router/createRouteURL'; import { ViewYaml } from '../advancedSearch/ResourceSearch'; import { DropZoneBox } from '../common/DropZoneBox'; import Table from '../common/Table'; import { KubeIcon } from '../resourceMap/kubeIcon/KubeIcon'; import { PROJECT_ID_LABEL, toKubernetesName } from './projectUtils'; async function createProjectFromYaml({ items, k8sName, cluster, setCreationState, }) { const itemsToCreate = structuredClone(items); itemsToCreate.forEach(item => { item.metadata.namespace = k8sName; }); const namespace = { kind: 'Namespace', apiVersion: 'v1', metadata: { name: k8sName, labels: { [PROJECT_ID_LABEL]: k8sName, }, }, }; setCreationState({ stage: 'creating', createdResources: [], creatingResource: namespace, }); await apply(namespace, cluster); for (const item of itemsToCreate) { setCreationState(state => ({ stage: 'creating', createdResources: state.stage === 'creating' ? [...state.createdResources, state.creatingResource] : [], creatingResource: item, })); await apply(item, cluster); } setCreationState({ stage: 'success', name: k8sName, }); } export function CreateNew() { const { t } = useTranslation(); const [items, setItems] = useState([]); const [name, setName] = useState(''); const allClusters = useClustersConf(); const [selectedClusters, setSelectedClusters] = useState(null); const k8sName = toKubernetesName(name); const history = useHistory(); const [creationState, setCreationState] = useState({ stage: 'form', }); const [errors, setErrors] = useState({}); const { items: allProjectNamespaces } = Namespace.useList({ clusters: allClusters ? Object.keys(allClusters) : [], labelSelector: PROJECT_ID_LABEL, }); const existingProjectNames = useMemo(() => { const result = new Set(); if (!allProjectNamespaces) { return result; } for (const ns of allProjectNamespaces) { const labelValue = ns.metadata.labels?.[PROJECT_ID_LABEL]; if (!labelValue) { continue; } // Store both the raw label and its Kubernetes-normalized form so that // duplicate detection works regardless of how PROJECT_ID_LABEL was set. result.add(labelValue); result.add(toKubernetesName(labelValue)); } return result; }, [allProjectNamespaces]); const projectNameExists = k8sName.length > 0 && existingProjectNames.has(k8sName); // New state for URL and tab management const [currentTab, setCurrentTab] = useState(0); const [yamlUrl, setYamlUrl] = useState(''); const [isLoadingFromUrl, setIsLoadingFromUrl] = useState(false); // Function to load YAML from URL const loadFromUrl = async () => { if (!yamlUrl.trim()) { setErrors(prev => ({ ...prev, url: t('URL is required') })); return; } setIsLoadingFromUrl(true); setErrors(prev => ({ ...prev, url: '' })); try { const response = await fetch(yamlUrl); if (!response.ok) { throw new Error(`HTTP ${response.status}: ${response.statusText}`); } const content = await response.text(); const docs = loadAll(content); const validDocs = docs.filter(doc => !!doc); setItems(validDocs); setErrors(prev => ({ ...prev, items: '' })); } catch (error) { setErrors(prev => ({ ...prev, url: t('Failed to load from URL: {{error}}', { error: error.message, }), })); } finally { setIsLoadingFromUrl(false); } }; // File drop functionality const onDrop = (acceptedFiles) => { setErrors(prev => ({ ...prev, items: '' })); const promises = acceptedFiles.map(file => { return new Promise((resolve, reject) => { const reader = new FileReader(); reader.onload = () => { const content = reader.result; try { const docs = loadAll(content); const validDocs = docs.filter(doc => !!doc); resolve({ docs: validDocs }); } catch (err) { console.error('Error parsing YAML file:', file.name, err); // Resolve with empty array for failed files resolve({ docs: [] }); } }; reader.onerror = err => { console.error('Error reading file:', file.name, err); reject(err); }; reader.readAsText(file); }); }); Promise.all(promises) .then(results => { const newDocs = results.flatMap(result => result.docs); setItems(prevItems => [...prevItems, ...newDocs]); }) .catch(err => { console.error('An error occurred while processing files.', err); setErrors(prev => ({ ...prev, items: t('Error processing files: {{error}}', { error: err.message, }), })); }); }; const { getRootProps, getInputProps, open } = useDropzone({ onDrop, accept: { 'application/x-yaml': ['.yaml', '.yml'], 'text/yaml': ['.yaml', '.yml'], 'text/plain': ['.yaml', '.yml'], }, multiple: true, }); const handleCreate = async (e) => { e.preventDefault(); const errors = {}; if (!name.trim()) { errors.name = t('Name is required'); } if (projectNameExists) { errors.name = t('A project with this name already exists'); } if (!selectedClusters) { errors.clusters = t('Cluster is required'); } if (items.length === 0) { errors.items = t('No resources have been uploaded'); } if (Object.keys(errors).length > 0) { setErrors(errors); return; } else { setErrors({}); } try { await createProjectFromYaml({ items, k8sName, cluster: selectedClusters, setCreationState, }); history.push(createRouteURL('projectDetails', { name: k8sName })); } catch (e) { setCreationState({ stage: 'error', error: e, }); } }; return (_jsx(_Fragment, { children: _jsxs(DialogContent, { children: [creationState.stage === 'form' && (_jsx(_Fragment, { children: _jsxs("form", { onSubmit: handleCreate, children: [_jsx(Typography, { variant: "h1", sx: { mb: 3 }, children: t('Create new Project from YAML') }), _jsxs(Grid, { container: true, spacing: 4, children: [_jsxs(Grid, { item: true, xs: 3, children: [_jsx(Typography, { children: _jsx(Trans, { children: "Project name" }) }), _jsx(Typography, { variant: "body2", color: "text.secondary", sx: { mb: 1 }, children: _jsx(Trans, { children: "Give your project a descriptive name" }) })] }), _jsx(Grid, { item: true, xs: 9, children: _jsx(TextField, { required: true, label: t('Project Name'), placeholder: t('Enter a name'), variant: "outlined", size: "small", sx: { minWidth: 400 }, value: name, onChange: e => setName(e.target.value), error: !!errors.name || projectNameExists, helperText: errors.name || (projectNameExists ? t('A project with this name already exists') : undefined) }) }), _jsxs(Grid, { item: true, xs: 3, children: [_jsx(Typography, { children: _jsx(Trans, { children: "Cluster" }) }), _jsx(Typography, { variant: "body2", color: "text.secondary", sx: { mb: 1 }, children: _jsx(Trans, { children: "Select cluster for this project" }) })] }), _jsx(Grid, { item: true, xs: 9, children: _jsx(Autocomplete, { options: allClusters ? Object.keys(allClusters) : [], value: selectedClusters, onChange: (e, newValue) => { setSelectedClusters(newValue); }, renderInput: params => (_jsx(TextField, { ...params, label: t('Clusters'), variant: "outlined", size: "small", sx: { maxWidth: 400 }, required: true })), noOptionsText: t('No available clusters'), disabled: !allClusters || Object.keys(allClusters).length === 0 }) }), _jsxs(Grid, { item: true, xs: 3, children: [_jsx(Typography, { children: t('Load resources') }), _jsx(Typography, { variant: "body2", color: "text.secondary", sx: { mb: 1 }, children: t('Upload files or load from URL') })] }), _jsxs(Grid, { item: true, xs: 9, children: [errors.items && _jsx(Typography, { color: "error", children: errors.items }), _jsxs(Box, { sx: { width: '100%' }, children: [_jsx(Box, { sx: { borderBottom: 1, borderColor: 'divider' }, children: _jsxs(Tabs, { value: currentTab, onChange: (_, newValue) => setCurrentTab(newValue), children: [_jsx(Tab, { label: t('Upload Files') }), _jsx(Tab, { label: t('Load from URL') })] }) }), currentTab === 0 && (_jsx(Box, { sx: { pt: 2 }, children: _jsxs(DropZoneBox, { border: 1, borderColor: "secondary.main", ...getRootProps(), children: [_jsxs(FormControl, { children: [_jsx("input", { ...getInputProps(), "aria-label": t('Choose Files') }), _jsx(Tooltip, { title: t('Drag & drop YAML files here or click to choose files'), placement: "top", children: _jsx(Button, { variant: "contained", onClick: open, startIcon: _jsx(InlineIcon, { icon: "mdi:upload", width: 24 }), children: t('Choose Files') }) })] }), _jsx(Typography, { variant: "body2", color: "text.secondary", sx: { mt: 1 }, children: t('Supports .yaml and .yml files') })] }) })), currentTab === 1 && (_jsxs(Box, { sx: { pt: 2 }, children: [_jsxs(Box, { sx: { display: 'flex', gap: 2, alignItems: 'flex-start' }, children: [_jsx(TextField, { fullWidth: true, label: t('YAML URL'), placeholder: t('Enter URL to YAML file'), variant: "outlined", size: "small", value: yamlUrl, onChange: e => setYamlUrl(e.target.value), error: !!errors.url, helperText: errors.url, disabled: isLoadingFromUrl }), _jsx(Button, { variant: "contained", onClick: loadFromUrl, disabled: isLoadingFromUrl || !yamlUrl.trim(), startIcon: isLoadingFromUrl ? (_jsx(CircularProgress, { size: 16 })) : (_jsx(InlineIcon, { icon: "mdi:download", width: 24 })), children: isLoadingFromUrl ? t('Loading...') : t('Load') })] }), _jsx(Typography, { variant: "body2", color: "text.secondary", sx: { mt: 1 }, children: t('Load YAML resources from a remote URL') })] }))] })] })] }), items.length > 0 && (_jsxs(Box, { sx: { display: 'flex', flexDirection: 'column', gap: 3, mt: 3 }, children: [_jsxs(Box, { sx: { display: 'flex', justifyContent: 'space-between', alignItems: 'center' }, children: [_jsx(Typography, { children: t('Loaded Resources ({{count}})', { count: items.length }) }), _jsx(Button, { variant: "outlined", size: "small", onClick: () => setItems([]), startIcon: _jsx(InlineIcon, { icon: "mdi:delete", width: 16 }), children: t('Clear All') })] }), _jsx(Box, { sx: { display: 'flex', flexDirection: 'column', }, children: _jsx(Table, { data: items, columns: [ { id: 'kind', header: t('Kind'), accessorFn: item => item.kind, Cell: ({ row: { original: item } }) => (_jsxs(Box, { sx: { display: 'flex', alignItems: 'center', gap: 1 }, children: [_jsx(KubeIcon, { kind: item.kind, width: "24px", height: "24px" }), _jsx(Typography, { variant: "body2", color: "text.secondary", children: item.kind })] })), gridTemplate: 'min-content', }, { id: 'name', header: t('Name'), accessorFn: item => item.metadata.name, }, { id: 'apiVersion', header: t('API Version'), accessorFn: item => item.apiVersion, }, { id: 'actions', header: t('Actions'), gridTemplate: 'min-content', accessorFn: item => item.metadata.uid, Cell: ({ row: { original: item } }) => (_jsx(ViewYaml, { item: { ...item, jsonData: item } })), }, ] }) })] })), _jsxs(DialogActions, { children: [_jsx(Button, { variant: "contained", color: "secondary", onClick: () => { history.push(createRouteURL('chooser')); }, children: _jsx(Trans, { children: "Cancel" }) }), _jsx(Button, { variant: "contained", type: "submit", children: _jsx(Trans, { children: "Create" }) })] })] }) })), creationState.stage === 'creating' && (_jsxs(Box, { children: [_jsx(Typography, { variant: "h1", children: t('Creating project') }), _jsx(Typography, { variant: "body2", color: "text.secondary", sx: { mt: 2 }, children: t('Creating following resources in this project:') }), _jsxs(Box, { sx: { display: 'flex', flexDirection: 'column', gap: 1, mt: 1 }, children: [creationState.createdResources.map((resource, index) => (_jsxs(Box, { sx: { display: 'flex', alignItems: 'center', gap: 1 }, children: [_jsx(KubeIcon, { kind: resource.kind, width: "24px", height: "24px" }), _jsx(Box, { children: resource.metadata.name }), _jsx(Box, { sx: theme => ({ color: theme.palette.success.main }), children: _jsx(Icon, { icon: "mdi:check" }) })] }, `created-${resource.kind}-${resource.metadata.name}-${index}`))), _jsxs(Box, { sx: { display: 'flex', alignItems: 'center', gap: 1 }, children: [_jsx(KubeIcon, { kind: creationState.creatingResource.kind, width: "24px", height: "24px" }), _jsx(Box, { children: creationState.creatingResource.metadata.name }), _jsx(CircularProgress, { size: "1rem" })] })] })] })), creationState.stage === 'error' && (_jsxs(Box, { children: [_jsx(Box, { children: t('Something went wrong') }), _jsx(Box, { children: creationState.error.message })] })), creationState.stage === 'success' && (_jsx(Redirect, { to: createRouteURL('projectDetails', { name: creationState.name }) }))] }) })); }