diff --git a/workspaces/frontend/src/__mocks__/mockResources.ts b/workspaces/frontend/src/__mocks__/mockResources.ts new file mode 100644 index 000000000..653b92634 --- /dev/null +++ b/workspaces/frontend/src/__mocks__/mockResources.ts @@ -0,0 +1,16 @@ +import { WorkspaceKindPodConfigValue } from '~/app/types'; + +export const mockPodConfig: WorkspaceKindPodConfigValue = { + id: 'pod_config_35', + displayName: '8000m CPU, 2Gi RAM, 1 GPU', + description: 'Pod with 8000m CPU, 2Gi RAM, and 1 GPU', + labels: [ + { key: 'cpu', value: '8000m' }, + { key: 'memory', value: '2Gi' }, + ], + hidden: false, + resources: { + requests: { cpu: '8000m', memory: '2Gi' }, + limits: { 'nvidia.com/gpu': '2' }, + }, +}; diff --git a/workspaces/frontend/src/app/AppRoutes.tsx b/workspaces/frontend/src/app/AppRoutes.tsx index c26d7ed28..0251d74ed 100644 --- a/workspaces/frontend/src/app/AppRoutes.tsx +++ b/workspaces/frontend/src/app/AppRoutes.tsx @@ -68,6 +68,7 @@ const AppRoutes: React.FC = () => { } /> } /> } /> + } /> } /> } /> { diff --git a/workspaces/frontend/src/app/pages/WorkspaceKinds/Form/WorkspaceKindFormLabels.tsx b/workspaces/frontend/src/app/pages/WorkspaceKinds/Form/EditableLabels.tsx similarity index 96% rename from workspaces/frontend/src/app/pages/WorkspaceKinds/Form/WorkspaceKindFormLabels.tsx rename to workspaces/frontend/src/app/pages/WorkspaceKinds/Form/EditableLabels.tsx index aeab95f3b..5d7348c29 100644 --- a/workspaces/frontend/src/app/pages/WorkspaceKinds/Form/WorkspaceKindFormLabels.tsx +++ b/workspaces/frontend/src/app/pages/WorkspaceKinds/Form/EditableLabels.tsx @@ -68,15 +68,12 @@ const EditableRow: React.FC = ({ type ColumnNames = { [K in keyof T]: string }; -interface WorkspaceKindFormLabelTableProps { +interface EditableLabelsProps { rows: WorkspaceOptionLabel[]; setRows: (value: WorkspaceOptionLabel[]) => void; } -export const WorkspaceKindFormLabelTable: React.FC = ({ - rows, - setRows, -}) => { +export const EditableLabels: React.FC = ({ rows, setRows }) => { const columnNames: ColumnNames = { key: 'Key', value: 'Value', diff --git a/workspaces/frontend/src/app/pages/WorkspaceKinds/Form/WorkspaceKindForm.tsx b/workspaces/frontend/src/app/pages/WorkspaceKinds/Form/WorkspaceKindForm.tsx index a744cd0f2..2666f54be 100644 --- a/workspaces/frontend/src/app/pages/WorkspaceKinds/Form/WorkspaceKindForm.tsx +++ b/workspaces/frontend/src/app/pages/WorkspaceKinds/Form/WorkspaceKindForm.tsx @@ -8,16 +8,16 @@ import { PageGroup, PageSection, Stack, - ToggleGroup, - ToggleGroupItem, } from '@patternfly/react-core'; import { useTypedNavigate } from '~/app/routerHelper'; +import { useCurrentRouteKey } from '~/app/hooks/useCurrentRouteKey'; import useGenericObjectState from '~/app/hooks/useGenericObjectState'; import { useNotebookAPI } from '~/app/hooks/useNotebookAPI'; import { WorkspaceKindFormData } from '~/app/types'; import { WorkspaceKindFileUpload } from './fileUpload/WorkspaceKindFileUpload'; import { WorkspaceKindFormProperties } from './properties/WorkspaceKindFormProperties'; import { WorkspaceKindFormImage } from './image/WorkspaceKindFormImage'; +import { WorkspaceKindFormPodConfig } from './podConfig/WorkspaceKindFormPodConfig'; export enum WorkspaceKindFormView { Form, @@ -30,13 +30,10 @@ export const WorkspaceKindForm: React.FC = () => { const navigate = useTypedNavigate(); const { api } = useNotebookAPI(); // TODO: Detect mode by route - const [mode] = useState('create'); const [yamlValue, setYamlValue] = useState(''); const [isSubmitting, setIsSubmitting] = useState(false); - const [view, setView] = useState(WorkspaceKindFormView.FileUpload); const [validated, setValidated] = useState('default'); - const workspaceKindFileUploadId = 'workspace-kind-form-fileupload-view'; - + const mode = useCurrentRouteKey() === 'workspaceKindCreate' ? 'create' : 'edit'; const [data, setData, resetData] = useGenericObjectState({ properties: { displayName: '', @@ -51,19 +48,11 @@ export const WorkspaceKindForm: React.FC = () => { default: '', values: [], }, - }); - - const handleViewClick = useCallback( - (event: React.MouseEvent | React.KeyboardEvent | MouseEvent) => { - const { id } = event.currentTarget as HTMLElement; - setView( - id === workspaceKindFileUploadId - ? WorkspaceKindFormView.FileUpload - : WorkspaceKindFormView.Form, - ); + podConfig: { + default: '', + values: [], }, - [], - ); + }); const handleSubmit = useCallback(async () => { setIsSubmitting(true); @@ -101,39 +90,19 @@ export const WorkspaceKindForm: React.FC = () => { {`${mode === 'create' ? 'Create' : 'Edit'} workspace kind`} - {view === WorkspaceKindFormView.FileUpload + {mode === 'create' ? `Please upload or drag and drop a Workspace Kind YAML file.` : `View and edit the Workspace Kind's information. Some fields may not be represented in this form`} - {mode === 'edit' && ( - - - - - - - )} - {view === WorkspaceKindFormView.FileUpload && ( + {mode === 'create' && ( { setValidated={setValidated} /> )} - {view === WorkspaceKindFormView.Form && ( + {mode === 'edit' && ( <> { setData('imageConfig', imageInput); }} /> + { + setData('podConfig', podConfig); + }} + /> )} diff --git a/workspaces/frontend/src/app/pages/WorkspaceKinds/Form/__tests__/helpers.spec.ts b/workspaces/frontend/src/app/pages/WorkspaceKinds/Form/__tests__/helpers.spec.ts new file mode 100644 index 000000000..62137aea8 --- /dev/null +++ b/workspaces/frontend/src/app/pages/WorkspaceKinds/Form/__tests__/helpers.spec.ts @@ -0,0 +1,67 @@ +import { getResources } from '~/app/pages/WorkspaceKinds/Form/helpers'; +import { mockPodConfig } from '~/__mocks__/mockResources'; +import { WorkspaceKindPodConfigValue } from '~/app/types'; + +describe('getResources', () => { + it('should convert k8s resource object to PodResourceEntry array with correct structure', () => { + const result = getResources(mockPodConfig); + expect(result).toHaveLength(3); + + const cpu = result.find((r) => r.type === 'cpu'); + expect(cpu).toBeDefined(); + expect(cpu).toEqual({ + id: 'cpu-resource', + type: 'cpu', + request: '8000m', + limit: '', + }); + + const memory = result.find((r) => r.type === 'memory'); + expect(memory).toBeDefined(); + expect(memory).toEqual({ + id: 'memory-resource', + type: 'memory', + request: '2Gi', + limit: '', + }); + + // Check custom GPU resource + const gpu = result.find((r) => r.type === 'nvidia.com/gpu'); + expect(gpu).toBeDefined(); + expect(gpu?.type).toBe('nvidia.com/gpu'); + expect(gpu?.request).toBe(''); + expect(gpu?.limit).toBe('2'); + expect(gpu?.id).toMatch(/nvidia\.com\/gpu-/); + }); + + it(' handle empty or missing resources and return default CPU and memory entries', () => { + const emptyConfig: WorkspaceKindPodConfigValue = { + id: 'test-config', + displayName: 'Test Config', + description: 'Test Description', + labels: [], + hidden: false, + }; + + const result = getResources(emptyConfig); + + // Should return CPU and memory with empty values + expect(result).toHaveLength(2); + + const cpu = result.find((r) => r.type === 'cpu'); + expect(cpu).toEqual({ + id: 'cpu-resource', + type: 'cpu', + request: '', + limit: '', + }); + + const memory = result.find((r) => r.type === 'memory'); + expect(memory).toEqual({ + id: 'memory-resource', + type: 'memory', + request: '', + limit: '', + }); + }); +}); diff --git a/workspaces/frontend/src/app/pages/WorkspaceKinds/Form/fileUpload/WorkspaceKindFileUpload.tsx b/workspaces/frontend/src/app/pages/WorkspaceKinds/Form/fileUpload/WorkspaceKindFileUpload.tsx index eec7ccbca..eae9c918a 100644 --- a/workspaces/frontend/src/app/pages/WorkspaceKinds/Form/fileUpload/WorkspaceKindFileUpload.tsx +++ b/workspaces/frontend/src/app/pages/WorkspaceKinds/Form/fileUpload/WorkspaceKindFileUpload.tsx @@ -8,13 +8,10 @@ import { HelperTextItem, Content, } from '@patternfly/react-core'; -import { UpdateObjectAtPropAndValue } from '~/app/hooks/useGenericObjectState'; -import { WorkspaceKindFormData } from '~/app/types'; import { isValidWorkspaceKindYaml } from '~/app/pages/WorkspaceKinds/Form/helpers'; import { ValidationStatus } from '~/app/pages/WorkspaceKinds/Form/WorkspaceKindForm'; interface WorkspaceKindFileUploadProps { - setData: UpdateObjectAtPropAndValue; value: string; setValue: (v: string) => void; resetData: () => void; @@ -23,7 +20,6 @@ interface WorkspaceKindFileUploadProps { } export const WorkspaceKindFileUpload: React.FC = ({ - setData, resetData, value, setValue, @@ -62,30 +58,13 @@ export const WorkspaceKindFileUpload: React.FC = ( if (isYamlFileRef.current) { try { const parsed = yaml.load(v); - if (isValidWorkspaceKindYaml(parsed)) { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - setData('properties', (parsed as any).spec.spawner); - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const parsedImg = (parsed as any).spec.podTemplate.options.imageConfig; - setData('imageConfig', { - default: parsedImg.spawner.default || '', - // eslint-disable-next-line @typescript-eslint/no-explicit-any - values: parsedImg.values.map((img: any) => { - const res = { - id: img.id, - redirect: img.redirect, - ...img.spawner, - ...img.spec, - }; - return res; - }), - }); - setValidated('success'); - setFileUploadHelperText(''); - } else { + if (!isValidWorkspaceKindYaml(parsed)) { setFileUploadHelperText('YAML is invalid: must follow WorkspaceKind format.'); setValidated('error'); resetData(); + } else { + setValidated('success'); + setFileUploadHelperText(''); } } catch (e) { console.error('Error parsing YAML:', e); @@ -94,7 +73,7 @@ export const WorkspaceKindFileUpload: React.FC = ( } } }, - [setValue, setData, setValidated, resetData], + [setValue, setValidated, resetData], ); const handleClear = useCallback(() => { diff --git a/workspaces/frontend/src/app/pages/WorkspaceKinds/Form/helpers.ts b/workspaces/frontend/src/app/pages/WorkspaceKinds/Form/helpers.ts index 39ce8bf5a..aad5f6226 100644 --- a/workspaces/frontend/src/app/pages/WorkspaceKinds/Form/helpers.ts +++ b/workspaces/frontend/src/app/pages/WorkspaceKinds/Form/helpers.ts @@ -1,5 +1,10 @@ -import { ImagePullPolicy, WorkspaceKindImagePort } from '~/app/types'; -import { WorkspaceOptionLabel } from '~/shared/api/backendApiTypes'; +import { ImagePullPolicy, WorkspaceKindImagePort, WorkspaceKindPodConfigValue } from '~/app/types'; +import { WorkspaceOptionLabel, WorkspacePodConfigValue } from '~/shared/api/backendApiTypes'; +import { PodResourceEntry } from './podConfig/WorkspaceKindFormResource'; + +// Simple ID generator to avoid PatternFly dependency in tests +export const generateUniqueId = (): string => + `id-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`; // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/explicit-module-boundary-types export const isValidWorkspaceKindYaml = (data: any): boolean => { @@ -88,3 +93,46 @@ export const emptyImage = { to: '', }, }; + +export const emptyPodConfig: WorkspacePodConfigValue = { + id: '', + displayName: '', + description: '', + labels: [], + hidden: false, + redirect: { + to: '', + }, +}; +// convert from k8s resource object {limits: {}, requests{}} to array of {type: '', limit: '', request: ''} for each type of resource (e.g. CPU, memory, nvidia.com/gpu) +export const getResources = (currConfig: WorkspaceKindPodConfigValue): PodResourceEntry[] => { + const grouped = new Map([ + ['cpu', { request: '', limit: '' }], + ['memory', { request: '', limit: '' }], + ]); + const { requests = {}, limits = {} } = currConfig.resources || {}; + const types = new Set([...Object.keys(requests), ...Object.keys(limits), 'cpu', 'memory']); + types.forEach((type) => { + const entry = grouped.get(type) || { request: '', limit: '' }; + if (type in requests) { + entry.request = String(requests[type]); + } + if (type in limits) { + entry.limit = String(limits[type]); + } + grouped.set(type, entry); + }); + + // Convert to UI-types with consistent IDs + return Array.from(grouped.entries()).map(([type, { request, limit }]) => ({ + id: + type === 'cpu' + ? 'cpu-resource' + : type === 'memory' + ? 'memory-resource' + : `${type}-${generateUniqueId()}`, + type, + request, + limit, + })); +}; diff --git a/workspaces/frontend/src/app/pages/WorkspaceKinds/Form/image/WorkspaceKindFormImageModal.tsx b/workspaces/frontend/src/app/pages/WorkspaceKinds/Form/image/WorkspaceKindFormImageModal.tsx index 097300624..99a7a22b1 100644 --- a/workspaces/frontend/src/app/pages/WorkspaceKinds/Form/image/WorkspaceKindFormImageModal.tsx +++ b/workspaces/frontend/src/app/pages/WorkspaceKinds/Form/image/WorkspaceKindFormImageModal.tsx @@ -14,7 +14,7 @@ import { HelperText, } from '@patternfly/react-core'; import { WorkspaceKindImageConfigValue, ImagePullPolicy } from '~/app/types'; -import { WorkspaceKindFormLabelTable } from '~/app/pages/WorkspaceKinds/Form/WorkspaceKindFormLabels'; +import { EditableLabels } from '~/app/pages/WorkspaceKinds/Form/EditableLabels'; import { emptyImage } from '~/app/pages/WorkspaceKinds/Form/helpers'; import { WorkspaceKindFormImageRedirect } from './WorkspaceKindFormImageRedirect'; @@ -100,7 +100,7 @@ export const WorkspaceKindFormImageModal: React.FC
Hidden
- Hide this image from users + Hide this image from users } aria-label="-controlled-check" @@ -109,7 +109,7 @@ export const WorkspaceKindFormImageModal: React.FC - setImage({ ...image, labels })} /> diff --git a/workspaces/frontend/src/app/pages/WorkspaceKinds/Form/podConfig/ResourceInputWrapper.tsx b/workspaces/frontend/src/app/pages/WorkspaceKinds/Form/podConfig/ResourceInputWrapper.tsx new file mode 100644 index 000000000..3c8ab7bb4 --- /dev/null +++ b/workspaces/frontend/src/app/pages/WorkspaceKinds/Form/podConfig/ResourceInputWrapper.tsx @@ -0,0 +1,146 @@ +import React, { useState, useEffect, useCallback, useMemo } from 'react'; +import { + FormSelect, + FormSelectOption, + NumberInput, + Split, + SplitItem, +} from '@patternfly/react-core'; +import { CPU_UNITS, MEMORY_UNITS_FOR_SELECTION, UnitOption } from '~/shared/utilities/valueUnits'; +import { parseResourceValue } from '~/shared/utilities/WorkspaceUtils'; + +interface ResourceInputWrapperProps { + value: string; + onChange: (value: string) => void; + type: 'cpu' | 'memory' | 'custom'; + min?: number; + max?: number; + step?: number; + placeholder?: string; + 'aria-label'?: string; + isDisabled?: boolean; +} + +const unitMap: { + [key: string]: UnitOption[]; +} = { + memory: MEMORY_UNITS_FOR_SELECTION, + cpu: CPU_UNITS, +}; + +const DEFAULT_STEP = 1; + +const DEFAULT_UNITS = { + memory: 'Mi', + cpu: '', +}; + +export const ResourceInputWrapper: React.FC = ({ + value, + onChange, + min = 1, + max, + step = DEFAULT_STEP, + type, + placeholder, + 'aria-label': ariaLabel, + isDisabled = false, +}) => { + const [inputValue, setInputValue] = useState(value); + const [unit, setUnit] = useState(''); + + useEffect(() => { + if (type === 'custom') { + setInputValue(value); + return; + } + const [numericValue, extractedUnit] = parseResourceValue(value, type); + setInputValue(String(numericValue || '')); + setUnit(extractedUnit?.unit || DEFAULT_UNITS[type]); + }, [value, type]); + + const handleInputChange = useCallback( + (newValue: string) => { + setInputValue(newValue); + if (type === 'custom') { + onChange(newValue); + } else { + onChange(newValue ? `${newValue}${unit}` : ''); + } + }, + [onChange, type, unit], + ); + + const handleUnitChange = useCallback( + (newUnit: string) => { + setUnit(newUnit); + if (inputValue) { + onChange(`${inputValue}${newUnit}`); + } + }, + [inputValue, onChange], + ); + + const handleIncrement = useCallback(() => { + const currentValue = parseFloat(inputValue) || 0; + const newValue = Math.min(currentValue + step, max || Infinity); + handleInputChange(newValue.toString()); + }, [inputValue, step, max, handleInputChange]); + + const handleDecrement = useCallback(() => { + const currentValue = parseFloat(inputValue) || 0; + const newValue = Math.max(currentValue - step, min); + handleInputChange(newValue.toString()); + }, [inputValue, step, min, handleInputChange]); + + const handleNumberInputChange = useCallback( + (event: React.FormEvent) => { + const newValue = (event.target as HTMLInputElement).value; + handleInputChange(newValue); + }, + [handleInputChange], + ); + + const unitOptions = useMemo( + () => + type !== 'custom' + ? unitMap[type].map((u) => ) + : [], + [type], + ); + + return ( + + + + + + {type !== 'custom' && ( + handleUnitChange(v)} + id={`${ariaLabel}-unit-select`} + isDisabled={isDisabled} + > + {unitOptions} + + )} + + + ); +}; diff --git a/workspaces/frontend/src/app/pages/WorkspaceKinds/Form/podConfig/WorkspaceKindFormPodConfig.tsx b/workspaces/frontend/src/app/pages/WorkspaceKinds/Form/podConfig/WorkspaceKindFormPodConfig.tsx new file mode 100644 index 000000000..400d1da04 --- /dev/null +++ b/workspaces/frontend/src/app/pages/WorkspaceKinds/Form/podConfig/WorkspaceKindFormPodConfig.tsx @@ -0,0 +1,227 @@ +import React, { useCallback, useState } from 'react'; +import { + Button, + Content, + Dropdown, + MenuToggle, + DropdownItem, + Modal, + ModalHeader, + ModalFooter, + ModalVariant, + EmptyState, + EmptyStateFooter, + EmptyStateActions, + ExpandableSection, + EmptyStateBody, + Label, + getUniqueId, +} from '@patternfly/react-core'; +import { Table, Thead, Tbody, Tr, Th, Td } from '@patternfly/react-table'; +import { PlusCircleIcon, EllipsisVIcon, CubesIcon } from '@patternfly/react-icons'; +import { emptyPodConfig } from '~/app/pages/WorkspaceKinds/Form/helpers'; +import { WorkspaceKindPodConfigValue, WorkspaceKindPodConfigData } from '~/app/types'; + +import { WorkspaceKindFormPodConfigModal } from './WorkspaceKindFormPodConfigModal'; + +interface WorkspaceKindFormPodConfigProps { + podConfig: WorkspaceKindPodConfigData; + updatePodConfig: (podConfigs: WorkspaceKindPodConfigData) => void; +} + +export const WorkspaceKindFormPodConfig: React.FC = ({ + podConfig, + updatePodConfig, +}) => { + const [isExpanded, setIsExpanded] = useState(false); + const [defaultId, setDefaultId] = useState(podConfig.default || ''); + const [isModalOpen, setIsModalOpen] = useState(false); + const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); + const [dropdownOpen, setDropdownOpen] = useState(null); + const [editIndex, setEditIndex] = useState(null); + const [deleteIndex, setDeleteIndex] = useState(null); + const [currConfig, setCurrConfig] = useState({ ...emptyPodConfig }); + + const clearForm = useCallback(() => { + setCurrConfig({ ...emptyPodConfig }); + setEditIndex(null); + setIsModalOpen(false); + }, []); + + const openDeleteModal = useCallback((i: number) => { + setIsDeleteModalOpen(true); + setDeleteIndex(i); + }, []); + + const handleAddOrEditSubmit = useCallback( + (config: WorkspaceKindPodConfigValue) => { + if (editIndex !== null) { + const updated = [...podConfig.values]; + updated[editIndex] = config; + updatePodConfig({ ...podConfig, values: updated }); + } else { + updatePodConfig({ ...podConfig, values: [...podConfig.values, config] }); + } + clearForm(); + }, + [clearForm, editIndex, podConfig, updatePodConfig], + ); + + const handleEdit = useCallback( + (index: number) => { + setCurrConfig(podConfig.values[index]); + setEditIndex(index); + setIsModalOpen(true); + }, + [podConfig.values], + ); + + const handleDelete = useCallback(() => { + if (deleteIndex === null) { + return; + } + updatePodConfig({ + default: podConfig.values[deleteIndex].id === defaultId ? '' : defaultId, + values: podConfig.values.filter((_, i) => i !== deleteIndex), + }); + if (podConfig.values[deleteIndex].id === defaultId) { + setDefaultId(''); + } + setDeleteIndex(null); + setIsDeleteModalOpen(false); + }, [deleteIndex, podConfig, updatePodConfig, setDefaultId, defaultId]); + + const addConfigBtn = ( + + ); + + return ( + +
+ setIsExpanded((prev) => !prev)} + isExpanded={isExpanded} + isIndented + > + {podConfig.values.length === 0 && ( + + + Configure specifications for pods and containers in your Workspace Kind + + + {addConfigBtn} + + + )} + {podConfig.values.length > 0 && ( + <> + + + + + + + + + + + + {podConfig.values.map((config, index) => ( + + + + + + + + + ))} + +
Display NameIDDefaultHiddenLabels +
{config.displayName}{config.id} + { + setDefaultId(config.id); + updatePodConfig({ ...podConfig, default: config.id }); + }} + aria-label={`Select ${config.id} as default`} + /> + {config.hidden ? 'Yes' : 'No'} + {config.labels.length > 0 && + config.labels.map((label) => ( + + ))} + + ( + setDropdownOpen(dropdownOpen === index ? null : index)} + variant="plain" + aria-label="plain kebab" + > + + + )} + isOpen={dropdownOpen === index} + onSelect={() => setDropdownOpen(null)} + popperProps={{ position: 'right' }} + > + handleEdit(index)}>Edit + openDeleteModal(index)}>Remove + +
+ {addConfigBtn} + + )} + + setIsDeleteModalOpen(false)} + variant={ModalVariant.small} + > + + + + + + +
+
+
+ ); +}; diff --git a/workspaces/frontend/src/app/pages/WorkspaceKinds/Form/podConfig/WorkspaceKindFormPodConfigModal.tsx b/workspaces/frontend/src/app/pages/WorkspaceKinds/Form/podConfig/WorkspaceKindFormPodConfigModal.tsx new file mode 100644 index 000000000..4d7ca6834 --- /dev/null +++ b/workspaces/frontend/src/app/pages/WorkspaceKinds/Form/podConfig/WorkspaceKindFormPodConfigModal.tsx @@ -0,0 +1,203 @@ +import React, { useState, useEffect, useCallback, useMemo } from 'react'; +import { + Modal, + ModalHeader, + ModalBody, + ModalFooter, + Button, + Form, + FormGroup, + TextInput, + Switch, + HelperText, +} from '@patternfly/react-core'; +import { WorkspaceKindPodConfigValue } from '~/app/types'; +import { WorkspaceOptionLabel } from '~/shared/api/backendApiTypes'; +import { EditableLabels } from '~/app/pages/WorkspaceKinds/Form/EditableLabels'; +import { getResources } from '~/app/pages/WorkspaceKinds/Form/helpers'; +import { WorkspaceKindFormResource, PodResourceEntry } from './WorkspaceKindFormResource'; + +interface WorkspaceKindFormPodConfigModalProps { + isOpen: boolean; + onClose: () => void; + onSubmit: (podConfig: WorkspaceKindPodConfigValue) => void; + editIndex: number | null; + currConfig: WorkspaceKindPodConfigValue; + setCurrConfig: (currConfig: WorkspaceKindPodConfigValue) => void; +} + +export const WorkspaceKindFormPodConfigModal: React.FC = ({ + isOpen, + onClose, + onSubmit, + editIndex, + currConfig, + setCurrConfig, +}) => { + const initialResources = useMemo(() => getResources(currConfig), [currConfig]); + + const [resources, setResources] = useState(initialResources); + const [labels, setLabels] = useState(currConfig.labels); + const [id, setId] = useState(currConfig.id); + const [displayName, setDisplayName] = useState(currConfig.displayName); + const [description, setDescription] = useState(currConfig.description); + const [hidden, setHidden] = useState(currConfig.hidden || false); + + useEffect(() => { + setResources(getResources(currConfig)); + setId(currConfig.id); + setDisplayName(currConfig.displayName); + setDescription(currConfig.description); + setHidden(currConfig.hidden || false); + setLabels(currConfig.labels); + }, [currConfig, isOpen, editIndex]); + + // merge resource entries to k8s resources type + // resources: {requests: {}, limits: {}} + const mergeResourceLabels = useCallback((resourceEntries: PodResourceEntry[]) => { + const parsedResources = resourceEntries.reduce( + (acc, r) => { + if (r.type.length) { + if (r.limit.length) { + acc.limits[r.type] = r.limit; + } + if (r.request.length) { + acc.requests[r.type] = r.request; + } + } + return acc; + }, + { requests: {}, limits: {} } as { + requests: { [key: string]: string }; + limits: { [key: string]: string }; + }, + ); + return parsedResources; + }, []); + + const handleSubmit = useCallback(() => { + const updatedConfig = { + ...currConfig, + id, + displayName, + description, + hidden, + resources: mergeResourceLabels(resources), + labels, + }; + setCurrConfig(updatedConfig); + onSubmit(updatedConfig); + }, [ + currConfig, + description, + displayName, + hidden, + id, + labels, + mergeResourceLabels, + onSubmit, + resources, + setCurrConfig, + ]); + + const cpuResource: PodResourceEntry = useMemo( + () => + resources.find((r) => r.type === 'cpu') || { + id: 'cpu-resource', + type: 'cpu', + request: '', + limit: '', + }, + [resources], + ); + + const memoryResource: PodResourceEntry = useMemo( + () => + resources.find((r) => r.type === 'memory') || { + id: 'memory-resource', + type: 'memory', + request: '', + limit: '', + }, + [resources], + ); + + const customResources: PodResourceEntry[] = useMemo( + () => resources.filter((r) => r.type !== 'cpu' && r.type !== 'memory'), + [resources], + ); + + return ( + + + +
+ + setId(value)} + id="workspace-kind-pod-config-id" + /> + + + setDisplayName(value)} + id="workspace-kind-pod-config-name" + /> + + + setDescription(value)} + id="workspace-kind-pod-config-description" + /> + + + +
Hidden
+ Hide this Pod Config from users + + } + aria-label="pod config hidden controlled check" + onChange={() => setHidden(!hidden)} + id="workspace-kind-pod-config-hidden" + name="check5" + /> +
+ setLabels(newLabels)} /> + + +
+ + + + +
+ ); +}; diff --git a/workspaces/frontend/src/app/pages/WorkspaceKinds/Form/podConfig/WorkspaceKindFormResource.tsx b/workspaces/frontend/src/app/pages/WorkspaceKinds/Form/podConfig/WorkspaceKindFormResource.tsx new file mode 100644 index 000000000..19c8d0edd --- /dev/null +++ b/workspaces/frontend/src/app/pages/WorkspaceKinds/Form/podConfig/WorkspaceKindFormResource.tsx @@ -0,0 +1,379 @@ +import React, { useCallback, useEffect, useState, useMemo } from 'react'; +import { + Button, + Grid, + GridItem, + Title, + FormFieldGroupExpandable, + FormFieldGroupHeader, + TextInput, + Checkbox, + HelperText, + HelperTextItem, +} from '@patternfly/react-core'; +import { PlusCircleIcon, TrashAltIcon } from '@patternfly/react-icons'; +import { generateUniqueId } from '~/app/pages/WorkspaceKinds/Form/helpers'; +import { isMemoryLimitLarger } from '~/shared/utilities/valueUnits'; +import { ResourceInputWrapper } from './ResourceInputWrapper'; + +export type PodResourceEntry = { + id: string; // Unique identifier for each resource entry + type: string; + request: string; + limit: string; +}; + +interface WorkspaceKindFormResourceProps { + setResources: (value: React.SetStateAction) => void; + cpu: PodResourceEntry; + memory: PodResourceEntry; + custom: PodResourceEntry[]; +} + +export const WorkspaceKindFormResource: React.FC = ({ + setResources, + cpu, + memory, + custom, +}) => { + // State for tracking limit toggles + const [cpuRequestEnabled, setCpuRequestEnabled] = useState(cpu.request.length > 0); + const [memoryRequestEnabled, setMemoryRequestEnabled] = useState(memory.request.length > 0); + const [cpuLimitEnabled, setCpuLimitEnabled] = useState(cpu.limit.length > 0); + const [memoryLimitEnabled, setMemoryLimitEnabled] = useState(memory.limit.length > 0); + const [customLimitsEnabled, setCustomLimitsEnabled] = useState>(() => { + const customToggles: Record = {}; + custom.forEach((res) => { + if (res.limit) { + customToggles[res.id] = true; + } + }); + return customToggles; + }); + + useEffect(() => { + setCpuRequestEnabled(cpu.request.length > 0); + setMemoryRequestEnabled(memory.request.length > 0); + setCpuLimitEnabled(cpu.request.length > 0 && cpu.limit.length > 0); + setMemoryLimitEnabled(memory.request.length > 0 && memory.limit.length > 0); + }, [cpu.limit.length, cpu.request.length, memory.limit.length, memory.request.length]); + + const handleChange = useCallback( + (resourceId: string, field: 'type' | 'request' | 'limit', value: string) => { + setResources((resources: PodResourceEntry[]) => + resources.map((r) => (r.id === resourceId ? { ...r, [field]: value } : r)), + ); + }, + [setResources], + ); + + const handleAddCustom = useCallback(() => { + setResources((resources: PodResourceEntry[]) => [ + ...resources, + { id: generateUniqueId(), type: '', request: '1', limit: '' }, + ]); + }, [setResources]); + + const handleRemoveCustom = useCallback( + (resourceId: string) => { + setResources((resources: PodResourceEntry[]) => resources.filter((r) => r.id !== resourceId)); + // Remove the corresponding limit toggle + const newCustomLimitsEnabled = { ...customLimitsEnabled }; + delete newCustomLimitsEnabled[resourceId]; + setCustomLimitsEnabled(newCustomLimitsEnabled); + }, + [customLimitsEnabled, setResources], + ); + + const handleCpuLimitToggle = useCallback( + (enabled: boolean) => { + setCpuLimitEnabled(enabled); + handleChange(cpu.id, 'limit', cpu.request); + if (!enabled) { + handleChange(cpu.id, 'limit', ''); + } + }, + [cpu.id, cpu.request, handleChange], + ); + + const handleCpuRequestToggle = useCallback( + (enabled: boolean) => { + setCpuRequestEnabled(enabled); + handleChange(cpu.id, 'request', '1'); + if (!enabled) { + handleChange(cpu.id, 'request', ''); + handleCpuLimitToggle(enabled); + } + }, + [cpu.id, handleChange, handleCpuLimitToggle], + ); + + const handleMemoryLimitToggle = useCallback( + (enabled: boolean) => { + setMemoryLimitEnabled(enabled); + handleChange(memory.id, 'limit', memory.request); + if (!enabled) { + handleChange(memory.id, 'limit', ''); + } + }, + [handleChange, memory.id, memory.request], + ); + + const handleMemoryRequestToggle = useCallback( + (enabled: boolean) => { + setMemoryRequestEnabled(enabled); + handleChange(memory.id, 'request', '1Mi'); + if (!enabled) { + handleChange(memory.id, 'request', ''); + handleMemoryLimitToggle(enabled); + } + }, + [handleChange, handleMemoryLimitToggle, memory.id], + ); + + const handleCustomLimitToggle = useCallback( + (resourceId: string, enabled: boolean) => { + setCustomLimitsEnabled((prev) => ({ ...prev, [resourceId]: enabled })); + if (!enabled) { + handleChange(resourceId, 'limit', ''); + } + }, + [handleChange], + ); + const cpuRequestLargerThanLimit = useMemo( + () => parseFloat(cpu.request) > parseFloat(cpu.limit), + [cpu.request, cpu.limit], + ); + + const memoryRequestLargerThanLimit = useMemo( + () => + memory.request.length > 0 && + memory.limit.length > 0 && + !isMemoryLimitLarger(memory.request, memory.limit, true), + [memory.request, memory.limit], + ); + + const requestRequestLargerThanLimit = useMemo( + () => + custom.reduce( + (prev, curr) => prev || parseFloat(curr.request) > parseFloat(curr.limit), + false, + ), + [custom], + ); + + const getResourceCountText = useCallback(() => { + const standardResourceCount = (cpu.request ? 1 : 0) + (memory.request ? 1 : 0); + const customResourceCount = custom.length; + if (standardResourceCount > 0 && customResourceCount > 0) { + return `${standardResourceCount} standard and ${customResourceCount} custom resources added`; + } + if (standardResourceCount > 0) { + return `${standardResourceCount} standard resources added`; + } + if (customResourceCount > 0) { + return `${customResourceCount} custom resources added`; + } + return '0 added'; + }, [cpu.request, memory.request, custom.length]); + + return ( + +
+ Optional: Configure k8s Pod Resource Requests & Limits. +
+
+ {getResourceCountText()} +
+ + } + /> + } + > + Standard Resources + + + handleCpuRequestToggle(checked)} + isChecked={cpuRequestEnabled} + label="CPU Request" + /> + + + handleMemoryRequestToggle(checked)} + isChecked={memoryRequestEnabled} + label="Memory Request" + /> + + + handleChange(cpu.id, 'request', value)} + placeholder="e.g. 1" + min={1} + aria-label="CPU request" + isDisabled={!cpuRequestEnabled} + /> + + + handleChange(memory.id, 'request', value)} + placeholder="e.g. 512Mi" + min={1} + aria-label="Memory request" + isDisabled={!memoryRequestEnabled} + /> + + + handleCpuLimitToggle(checked)} + isChecked={cpuLimitEnabled} + label="CPU Limit" + isDisabled={!cpuRequestEnabled} + aria-label="Enable CPU limit" + /> + + + handleMemoryLimitToggle(checked)} + isChecked={memoryLimitEnabled} + isDisabled={!memoryRequestEnabled} + label="Memory Limit" + aria-label="Enable Memory limit" + /> + + + handleChange(cpu.id, 'limit', value)} + placeholder="e.g. 2" + min={parseFloat(cpu.request)} + step={1} + aria-label="CPU limit" + isDisabled={!cpuRequestEnabled || !cpuLimitEnabled} + /> + + + handleChange(memory.id, 'limit', value)} + placeholder="e.g. 1Gi" + min={parseFloat(memory.request)} + aria-label="Memory limit" + isDisabled={!memoryRequestEnabled || !memoryLimitEnabled} + /> + + + {cpuRequestLargerThanLimit && ( + + + CPU limit should not be smaller than the request value + + + )} + + + {memoryRequestLargerThanLimit && ( + + + Memory limit should not be smaller than the request value + + + )} + + + Custom Resources + {custom.map((res) => ( + + + handleChange(res.id, 'type', value)} + /> + + + + + Request + + handleChange(res.id, 'request', value)} + placeholder="Request" + min={1} + aria-label="Custom resource request" + /> + + + { + handleChange(res.id, 'limit', res.request); + handleCustomLimitToggle(res.id, checked); + }} + aria-label={`Enable limit for ${res.type || 'custom resource'}`} + /> + + + handleChange(res.id, 'limit', value)} + placeholder="Limit" + min={parseFloat(res.request)} + isDisabled={!customLimitsEnabled[res.id]} + aria-label={`${res.type || 'Custom resource'} limit`} + /> + + + ))} + + {requestRequestLargerThanLimit && ( + + + Resource limit should not be smaller than the request value + + + )} +
+ ); +}; diff --git a/workspaces/frontend/src/app/routes.ts b/workspaces/frontend/src/app/routes.ts index ee0cf4d5a..8379d179f 100644 --- a/workspaces/frontend/src/app/routes.ts +++ b/workspaces/frontend/src/app/routes.ts @@ -6,6 +6,7 @@ export const AppRoutePaths = { workspaceKinds: '/workspacekinds', workspaceKindSummary: '/workspacekinds/:kind/summary', workspaceKindCreate: '/workspacekinds/create', + workspaceKindEdit: '/workspacekinds/:kind/edit', } satisfies Record; export type AppRoute = (typeof AppRoutePaths)[keyof typeof AppRoutePaths]; @@ -31,6 +32,9 @@ export type RouteParamsMap = { kind: string; }; workspaceKindCreate: undefined; + workspaceKindEdit: { + kind: string; + }; }; /** @@ -62,6 +66,9 @@ export type RouteStateMap = { workspaceKindCreate: { namespace: string; }; + workspaceKindEdit: { + workspaceKindName: string; + }; }; /** @@ -82,4 +89,5 @@ export type RouteSearchParamsMap = { workspaceKinds: undefined; workspaceKindSummary: undefined; workspaceKindCreate: undefined; + workspaceKindEdit: undefined; }; diff --git a/workspaces/frontend/src/app/types.ts b/workspaces/frontend/src/app/types.ts index ccc1523b6..d0dcaf9be 100644 --- a/workspaces/frontend/src/app/types.ts +++ b/workspaces/frontend/src/app/types.ts @@ -72,12 +72,29 @@ export interface WorkspaceKindImagePort { protocol: 'HTTP'; // ONLY HTTP is supported at the moment, per https://github.com/thesuperzapper/kubeflow-notebooks-v2-design/blob/main/crds/workspace-kind.yaml#L275 } +export interface WorkspaceKindPodConfigValue extends WorkspacePodConfigValue { + resources?: { + requests: { + [key: string]: string; + }; + limits: { + [key: string]: string; + }; + }; +} + export interface WorkspaceKindImageConfigData { default: string; values: WorkspaceKindImageConfigValue[]; } +export interface WorkspaceKindPodConfigData { + default: string; + values: WorkspaceKindPodConfigValue[]; +} + export interface WorkspaceKindFormData { properties: WorkspaceKindProperties; imageConfig: WorkspaceKindImageConfigData; + podConfig: WorkspaceKindPodConfigData; } diff --git a/workspaces/frontend/src/shared/style/MUI-theme.scss b/workspaces/frontend/src/shared/style/MUI-theme.scss index ec9aa1579..6084f158d 100644 --- a/workspaces/frontend/src/shared/style/MUI-theme.scss +++ b/workspaces/frontend/src/shared/style/MUI-theme.scss @@ -316,6 +316,22 @@ outline: none; } +.mui-theme .workspacekind-form-resource-input, +.custom-resource-type-input { + // Make sure input and select have the same height in ResourceInputWrapper + .pf-v6-c-form-control { + --pf-v6-c-form-control--PaddingBlockStart: var(--mui-spacing-8px); + --pf-v6-c-form-control--PaddingBlockEnd: var(--mui-spacing-8px); + + &:has(select) { + --pf-v6-c-form-control--PaddingInlineEnd: calc( + var(--pf-v6-c-form-control__select--PaddingInlineEnd) + + var(--pf-v6-c-form-control__icon--FontSize) + ); + } + } +} + .mui-theme .pf-v6-c-text-input-group__text-input:focus { --pf-v6-c-form-control--OutlineOffset: none; outline: none; diff --git a/workspaces/frontend/src/shared/utilities/WorkspaceUtils.ts b/workspaces/frontend/src/shared/utilities/WorkspaceUtils.ts index a732b4af1..ff07e661b 100644 --- a/workspaces/frontend/src/shared/utilities/WorkspaceUtils.ts +++ b/workspaces/frontend/src/shared/utilities/WorkspaceUtils.ts @@ -13,6 +13,20 @@ export enum YesNoValue { No = 'No', } +const RESOURCE_UNIT_CONFIG = { + cpu: CPU_UNITS, + memory: MEMORY_UNITS_FOR_PARSING, + gpu: OTHER, +}; + +export const parseResourceValue = ( + value: string, + resourceType: ResourceType, +): [number | undefined, { name: string; unit: string } | undefined] => { + const units = RESOURCE_UNIT_CONFIG[resourceType]; + return splitValueUnit(value, units); +}; + export const extractResourceValue = ( workspace: Workspace, resourceType: ResourceType, @@ -24,18 +38,13 @@ export const formatResourceValue = (v: string | undefined, resourceType?: Resour if (v === undefined) { return '-'; } - switch (resourceType) { - case 'cpu': { - const [cpuValue, cpuUnit] = splitValueUnit(v, CPU_UNITS); - return `${cpuValue ?? ''} ${cpuUnit.name}`; - } - case 'memory': { - const [memoryValue, memoryUnit] = splitValueUnit(v, MEMORY_UNITS_FOR_PARSING); - return `${memoryValue ?? ''} ${memoryUnit.name}`; - } - default: - return v; + + if (!resourceType) { + return v; } + + const [value, unit] = parseResourceValue(v, resourceType); + return `${value || ''} ${unit?.name || ''}`.trim(); }; export const formatResourceFromWorkspace = ( diff --git a/workspaces/frontend/src/shared/utilities/valueUnits.ts b/workspaces/frontend/src/shared/utilities/valueUnits.ts index 9efb05d0a..5017cf6ed 100644 --- a/workspaces/frontend/src/shared/utilities/valueUnits.ts +++ b/workspaces/frontend/src/shared/utilities/valueUnits.ts @@ -19,7 +19,7 @@ export type UnitOption = { export const CPU_UNITS: UnitOption[] = [ { name: 'Cores', unit: '', weight: 1000 }, - { name: 'Milicores', unit: 'm', weight: 1 }, + { name: 'Millicores', unit: 'm', weight: 1 }, ]; export const MEMORY_UNITS_FOR_SELECTION: UnitOption[] = [ { name: 'GiB', unit: 'Gi', weight: 1024 }, @@ -40,7 +40,7 @@ export const MEMORY_UNITS_FOR_PARSING: UnitOption[] = [ { name: 'KiB', unit: 'Ki', weight: 1024 }, { name: 'B', unit: '', weight: 1 }, ]; -export const OTHER: UnitOption[] = [{ name: 'Other', unit: '', weight: 1 }]; +export const OTHER: UnitOption[] = [{ name: '', unit: '', weight: 1 }]; export const splitValueUnit = ( value: ValueUnitString,