feat: workspace kind Edit Pod Configs (#425)

* Add Pod Config to WorkspaceKind form

Signed-off-by: Charles Thao <cthao@redhat.com>

* Add resource section for PodConfig

Signed-off-by: Charles Thao <cthao@redhat.com>

* Use refactored types

Signed-off-by: Charles Thao <cthao@redhat.com>

* Improve Resource input

Signed-off-by: Charles Thao <cthao@redhat.com>

* Move form view to edit mode only

Signed-off-by: Charles Thao <cthao@redhat.com>

* Bug fix and improvements

Signed-off-by: Charles Thao <cthao@redhat.com>

---------

Signed-off-by: Charles Thao <cthao@redhat.com>
This commit is contained in:
Charles Thao 2025-07-04 10:38:20 -04:00 committed by Bhakti Narvekar
parent c6e81c2a77
commit d680ea03fd
17 changed files with 1178 additions and 90 deletions

View File

@ -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' },
},
};

View File

@ -68,6 +68,7 @@ const AppRoutes: React.FC = () => {
<Route path={AppRoutePaths.workspaceKindSummary} element={<WorkspaceKindSummaryWrapper />} />
<Route path={AppRoutePaths.workspaceKinds} element={<WorkspaceKinds />} />
<Route path={AppRoutePaths.workspaceKindCreate} element={<WorkspaceKindForm />} />
<Route path={AppRoutePaths.workspaceKindEdit} element={<WorkspaceKindForm />} />
<Route path="/" element={<Navigate to={AppRoutePaths.workspaces} replace />} />
<Route path="*" element={<NotFound />} />
{

View File

@ -68,15 +68,12 @@ const EditableRow: React.FC<EditableRowInterface> = ({
type ColumnNames<T> = { [K in keyof T]: string };
interface WorkspaceKindFormLabelTableProps {
interface EditableLabelsProps {
rows: WorkspaceOptionLabel[];
setRows: (value: WorkspaceOptionLabel[]) => void;
}
export const WorkspaceKindFormLabelTable: React.FC<WorkspaceKindFormLabelTableProps> = ({
rows,
setRows,
}) => {
export const EditableLabels: React.FC<EditableLabelsProps> = ({ rows, setRows }) => {
const columnNames: ColumnNames<WorkspaceOptionLabel> = {
key: 'Key',
value: 'Value',

View File

@ -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>(WorkspaceKindFormView.FileUpload);
const [validated, setValidated] = useState<ValidationStatus>('default');
const workspaceKindFileUploadId = 'workspace-kind-form-fileupload-view';
const mode = useCurrentRouteKey() === 'workspaceKindCreate' ? 'create' : 'edit';
const [data, setData, resetData] = useGenericObjectState<WorkspaceKindFormData>({
properties: {
displayName: '',
@ -51,19 +48,11 @@ export const WorkspaceKindForm: React.FC = () => {
default: '',
values: [],
},
});
const handleViewClick = useCallback(
(event: React.MouseEvent<unknown> | 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`}
</Content>
<Content component={ContentVariants.p}>
{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`}
</Content>
</FlexItem>
{mode === 'edit' && (
<FlexItem>
<ToggleGroup className="workspace-kind-form-header" aria-label="Toggle form view">
<ToggleGroupItem
text="YAML Upload"
buttonId={workspaceKindFileUploadId}
isSelected={view === WorkspaceKindFormView.FileUpload}
onChange={handleViewClick}
/>
<ToggleGroupItem
text="Form View"
buttonId="workspace-kind-form-form-view"
isSelected={view === WorkspaceKindFormView.Form}
onChange={handleViewClick}
isDisabled={yamlValue === '' || validated === 'error'}
/>
</ToggleGroup>
</FlexItem>
)}
</Flex>
</Stack>
</PageSection>
</PageGroup>
<PageSection isFilled>
{view === WorkspaceKindFormView.FileUpload && (
{mode === 'create' && (
<WorkspaceKindFileUpload
setData={setData}
resetData={resetData}
value={yamlValue}
setValue={setYamlValue}
@ -141,7 +110,7 @@ export const WorkspaceKindForm: React.FC = () => {
setValidated={setValidated}
/>
)}
{view === WorkspaceKindFormView.Form && (
{mode === 'edit' && (
<>
<WorkspaceKindFormProperties
mode={mode}
@ -155,6 +124,12 @@ export const WorkspaceKindForm: React.FC = () => {
setData('imageConfig', imageInput);
}}
/>
<WorkspaceKindFormPodConfig
podConfig={data.podConfig}
updatePodConfig={(podConfig) => {
setData('podConfig', podConfig);
}}
/>
</>
)}
</PageSection>

View File

@ -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: '',
});
});
});

View File

@ -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<WorkspaceKindFormData>;
value: string;
setValue: (v: string) => void;
resetData: () => void;
@ -23,7 +20,6 @@ interface WorkspaceKindFileUploadProps {
}
export const WorkspaceKindFileUpload: React.FC<WorkspaceKindFileUploadProps> = ({
setData,
resetData,
value,
setValue,
@ -62,30 +58,13 @@ export const WorkspaceKindFileUpload: React.FC<WorkspaceKindFileUploadProps> = (
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<WorkspaceKindFileUploadProps> = (
}
}
},
[setValue, setData, setValidated, resetData],
[setValue, setValidated, resetData],
);
const handleClear = useCallback(() => {

View File

@ -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<string, { request: string; limit: string }>([
['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,
}));
};

View File

@ -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<WorkspaceKindFormImageModalPr
label={
<div>
<div>Hidden</div>
<HelperText>Hide this image from users </HelperText>
<HelperText>Hide this image from users</HelperText>
</div>
}
aria-label="-controlled-check"
@ -109,7 +109,7 @@ export const WorkspaceKindFormImageModal: React.FC<WorkspaceKindFormImageModalPr
name="workspace-kind-image-hidden-switch"
/>
</FormGroup>
<WorkspaceKindFormLabelTable
<EditableLabels
rows={image.labels}
setRows={(labels) => setImage({ ...image, labels })}
/>

View File

@ -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<ResourceInputWrapperProps> = ({
value,
onChange,
min = 1,
max,
step = DEFAULT_STEP,
type,
placeholder,
'aria-label': ariaLabel,
isDisabled = false,
}) => {
const [inputValue, setInputValue] = useState(value);
const [unit, setUnit] = useState<string>('');
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<HTMLInputElement>) => {
const newValue = (event.target as HTMLInputElement).value;
handleInputChange(newValue);
},
[handleInputChange],
);
const unitOptions = useMemo(
() =>
type !== 'custom'
? unitMap[type].map((u) => <FormSelectOption label={u.name} key={u.name} value={u.unit} />)
: [],
[type],
);
return (
<Split className="workspacekind-form-resource-input">
<SplitItem>
<NumberInput
value={parseFloat(inputValue) || 1}
placeholder={placeholder}
onMinus={handleDecrement}
onChange={handleNumberInputChange}
onPlus={handleIncrement}
inputAriaLabel={ariaLabel}
minusBtnAriaLabel={`${ariaLabel}-minus`}
plusBtnAriaLabel={`${ariaLabel}-plus`}
inputName={`${ariaLabel}-input`}
id={ariaLabel}
isDisabled={isDisabled}
min={min}
max={max}
step={step}
/>
</SplitItem>
<SplitItem>
{type !== 'custom' && (
<FormSelect
value={unit}
onChange={(_, v) => handleUnitChange(v)}
id={`${ariaLabel}-unit-select`}
isDisabled={isDisabled}
>
{unitOptions}
</FormSelect>
)}
</SplitItem>
</Split>
);
};

View File

@ -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<WorkspaceKindFormPodConfigProps> = ({
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<number | null>(null);
const [editIndex, setEditIndex] = useState<number | null>(null);
const [deleteIndex, setDeleteIndex] = useState<number | null>(null);
const [currConfig, setCurrConfig] = useState<WorkspaceKindPodConfigValue>({ ...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 = (
<Button
variant="link"
icon={<PlusCircleIcon />}
onClick={() => {
setIsModalOpen(true);
}}
>
Add Config
</Button>
);
return (
<Content>
<div className="pf-u-mb-0">
<ExpandableSection
toggleText="Pod Configurations"
onToggle={() => setIsExpanded((prev) => !prev)}
isExpanded={isExpanded}
isIndented
>
{podConfig.values.length === 0 && (
<EmptyState
titleText="Start by creating a pod configuration"
headingLevel="h4"
icon={CubesIcon}
>
<EmptyStateBody>
Configure specifications for pods and containers in your Workspace Kind
</EmptyStateBody>
<EmptyStateFooter>
<EmptyStateActions>{addConfigBtn}</EmptyStateActions>
</EmptyStateFooter>
</EmptyState>
)}
{podConfig.values.length > 0 && (
<>
<Table aria-label="pod configs table">
<Thead>
<Tr>
<Th>Display Name</Th>
<Th>ID</Th>
<Th screenReaderText="Row select">Default</Th>
<Th>Hidden</Th>
<Th>Labels</Th>
<Th aria-label="Actions" />
</Tr>
</Thead>
<Tbody>
{podConfig.values.map((config, index) => (
<Tr key={config.id}>
<Td>{config.displayName}</Td>
<Td>{config.id}</Td>
<Td>
<input
type="radio"
name="default-podConfig"
checked={defaultId === config.id}
onChange={() => {
setDefaultId(config.id);
updatePodConfig({ ...podConfig, default: config.id });
}}
aria-label={`Select ${config.id} as default`}
/>
</Td>
<Td>{config.hidden ? 'Yes' : 'No'}</Td>
<Td>
{config.labels.length > 0 &&
config.labels.map((label) => (
<Label
style={{ marginRight: '4px', marginTop: '4px' }}
key={getUniqueId()}
>{`${label.key}: ${label.value}`}</Label>
))}
</Td>
<Td isActionCell>
<Dropdown
toggle={(toggleRef) => (
<MenuToggle
ref={toggleRef}
isExpanded={dropdownOpen === index}
onClick={() => setDropdownOpen(dropdownOpen === index ? null : index)}
variant="plain"
aria-label="plain kebab"
>
<EllipsisVIcon />
</MenuToggle>
)}
isOpen={dropdownOpen === index}
onSelect={() => setDropdownOpen(null)}
popperProps={{ position: 'right' }}
>
<DropdownItem onClick={() => handleEdit(index)}>Edit</DropdownItem>
<DropdownItem onClick={() => openDeleteModal(index)}>Remove</DropdownItem>
</Dropdown>
</Td>
</Tr>
))}
</Tbody>
</Table>
{addConfigBtn}
</>
)}
<WorkspaceKindFormPodConfigModal
isOpen={isModalOpen}
onClose={clearForm}
onSubmit={handleAddOrEditSubmit}
editIndex={editIndex}
currConfig={currConfig}
setCurrConfig={setCurrConfig}
/>
<Modal
isOpen={isDeleteModalOpen}
onClose={() => setIsDeleteModalOpen(false)}
variant={ModalVariant.small}
>
<ModalHeader
title="Remove Pod Config?"
description="The pod config will be removed from the workspace kind."
/>
<ModalFooter>
<Button key="remove" variant="danger" onClick={handleDelete}>
Remove
</Button>
<Button key="cancel" variant="link" onClick={() => setIsDeleteModalOpen(false)}>
Cancel
</Button>
</ModalFooter>
</Modal>
</ExpandableSection>
</div>
</Content>
);
};

View File

@ -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<WorkspaceKindFormPodConfigModalProps> = ({
isOpen,
onClose,
onSubmit,
editIndex,
currConfig,
setCurrConfig,
}) => {
const initialResources = useMemo(() => getResources(currConfig), [currConfig]);
const [resources, setResources] = useState<PodResourceEntry[]>(initialResources);
const [labels, setLabels] = useState<WorkspaceOptionLabel[]>(currConfig.labels);
const [id, setId] = useState(currConfig.id);
const [displayName, setDisplayName] = useState(currConfig.displayName);
const [description, setDescription] = useState(currConfig.description);
const [hidden, setHidden] = useState<boolean>(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 (
<Modal isOpen={isOpen} onClose={onClose} variant="medium">
<ModalHeader
title={editIndex === null ? 'Create A Pod Configuration' : 'Edit Pod Configuration'}
labelId="pod-config-modal-title"
description={editIndex === null ? 'Add a pod configuration to your Workspace Kind' : ''}
/>
<ModalBody>
<Form>
<FormGroup label="ID" isRequired fieldId="workspace-kind-pod-config-id">
<TextInput
isRequired
type="text"
value={id}
onChange={(_, value) => setId(value)}
id="workspace-kind-pod-config-id"
/>
</FormGroup>
<FormGroup label="Display Name" isRequired fieldId="workspace-kind-pod-config-name">
<TextInput
isRequired
type="text"
value={displayName}
onChange={(_, value) => setDisplayName(value)}
id="workspace-kind-pod-config-name"
/>
</FormGroup>
<FormGroup label="Description" fieldId="workspace-kind-pod-config-description">
<TextInput
type="text"
value={description}
onChange={(_, value) => setDescription(value)}
id="workspace-kind-pod-config-description"
/>
</FormGroup>
<FormGroup
isRequired
style={{ marginTop: 'var(--mui-spacing-16px)' }}
fieldId="workspace-kind-pod-config-hidden"
>
<Switch
isChecked={hidden}
label={
<div>
<div>Hidden</div>
<HelperText>Hide this Pod Config from users</HelperText>
</div>
}
aria-label="pod config hidden controlled check"
onChange={() => setHidden(!hidden)}
id="workspace-kind-pod-config-hidden"
name="check5"
/>
</FormGroup>
<EditableLabels rows={labels} setRows={(newLabels) => setLabels(newLabels)} />
<WorkspaceKindFormResource
setResources={setResources}
cpu={cpuResource}
memory={memoryResource}
custom={customResources}
/>
</Form>
</ModalBody>
<ModalFooter>
<Button key="confirm" variant="primary" onClick={handleSubmit}>
{editIndex !== null ? 'Save' : 'Create'}
</Button>
<Button key="cancel" variant="link" onClick={onClose}>
Cancel
</Button>
</ModalFooter>
</Modal>
);
};

View File

@ -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<PodResourceEntry[]>) => void;
cpu: PodResourceEntry;
memory: PodResourceEntry;
custom: PodResourceEntry[];
}
export const WorkspaceKindFormResource: React.FC<WorkspaceKindFormResourceProps> = ({
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<Record<string, boolean>>(() => {
const customToggles: Record<string, boolean> = {};
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 (
<FormFieldGroupExpandable
toggleAriaLabel="Resources"
header={
<FormFieldGroupHeader
titleText={{
text: 'Resources',
id: 'workspace-kind-podconfig-resource',
}}
titleDescription={
<>
<div style={{ fontSize: '12px' }}>
Optional: Configure k8s Pod Resource Requests & Limits.
</div>
<div className="pf-u-font-size-sm">
<strong>{getResourceCountText()}</strong>
</div>
</>
}
/>
}
>
<Title headingLevel="h6">Standard Resources</Title>
<Grid hasGutter className="pf-v6-u-mb-sm">
<GridItem span={6}>
<Checkbox
id="cpu-request-checkbox"
onChange={(_event, checked) => handleCpuRequestToggle(checked)}
isChecked={cpuRequestEnabled}
label="CPU Request"
/>
</GridItem>
<GridItem span={6}>
<Checkbox
id="memory-request-checkbox"
onChange={(_event, checked) => handleMemoryRequestToggle(checked)}
isChecked={memoryRequestEnabled}
label="Memory Request"
/>
</GridItem>
<GridItem span={6}>
<ResourceInputWrapper
type="cpu"
value={cpu.request}
onChange={(value) => handleChange(cpu.id, 'request', value)}
placeholder="e.g. 1"
min={1}
aria-label="CPU request"
isDisabled={!cpuRequestEnabled}
/>
</GridItem>
<GridItem span={6}>
<ResourceInputWrapper
type="memory"
value={memory.request}
onChange={(value) => handleChange(memory.id, 'request', value)}
placeholder="e.g. 512Mi"
min={1}
aria-label="Memory request"
isDisabled={!memoryRequestEnabled}
/>
</GridItem>
<GridItem span={6}>
<Checkbox
id="cpu-limit-checkbox"
onChange={(_event, checked) => handleCpuLimitToggle(checked)}
isChecked={cpuLimitEnabled}
label="CPU Limit"
isDisabled={!cpuRequestEnabled}
aria-label="Enable CPU limit"
/>
</GridItem>
<GridItem span={6}>
<Checkbox
id="memory-limit-checkbox"
onChange={(_event, checked) => handleMemoryLimitToggle(checked)}
isChecked={memoryLimitEnabled}
isDisabled={!memoryRequestEnabled}
label="Memory Limit"
aria-label="Enable Memory limit"
/>
</GridItem>
<GridItem span={6}>
<ResourceInputWrapper
type="cpu"
value={cpu.limit}
onChange={(value) => handleChange(cpu.id, 'limit', value)}
placeholder="e.g. 2"
min={parseFloat(cpu.request)}
step={1}
aria-label="CPU limit"
isDisabled={!cpuRequestEnabled || !cpuLimitEnabled}
/>
</GridItem>
<GridItem span={6}>
<ResourceInputWrapper
type="memory"
value={memory.limit}
onChange={(value) => handleChange(memory.id, 'limit', value)}
placeholder="e.g. 1Gi"
min={parseFloat(memory.request)}
aria-label="Memory limit"
isDisabled={!memoryRequestEnabled || !memoryLimitEnabled}
/>
</GridItem>
<GridItem span={6}>
{cpuRequestLargerThanLimit && (
<HelperText>
<HelperTextItem variant="error">
CPU limit should not be smaller than the request value
</HelperTextItem>
</HelperText>
)}
</GridItem>
<GridItem span={6}>
{memoryRequestLargerThanLimit && (
<HelperText>
<HelperTextItem variant="error">
Memory limit should not be smaller than the request value
</HelperTextItem>
</HelperText>
)}
</GridItem>
</Grid>
<Title headingLevel="h6">Custom Resources</Title>
{custom.map((res) => (
<Grid key={res.id} hasGutter className="pf-u-mb-sm">
<GridItem span={10} className="custom-resource-type-input">
<TextInput
value={res.type}
placeholder="Resource name (e.g. nvidia.com/gpu)"
aria-label="Custom resource type"
onChange={(_event, value) => handleChange(res.id, 'type', value)}
/>
</GridItem>
<GridItem span={2}>
<Button
variant="link"
isDanger
onClick={() => handleRemoveCustom(res.id)}
aria-label={`Remove ${res.type || 'custom resource'}`}
>
<TrashAltIcon />
</Button>
</GridItem>
<GridItem span={12}>Request</GridItem>
<GridItem span={12}>
<ResourceInputWrapper
type="custom"
value={res.request}
onChange={(value) => handleChange(res.id, 'request', value)}
placeholder="Request"
min={1}
aria-label="Custom resource request"
/>
</GridItem>
<GridItem span={12}>
<Checkbox
id={`custom-limit-switch-${res.id}`}
label="Set Limit"
isChecked={customLimitsEnabled[res.id] || false}
onChange={(_event, checked) => {
handleChange(res.id, 'limit', res.request);
handleCustomLimitToggle(res.id, checked);
}}
aria-label={`Enable limit for ${res.type || 'custom resource'}`}
/>
</GridItem>
<GridItem span={12}>
<ResourceInputWrapper
type="custom"
value={res.limit}
onChange={(value) => handleChange(res.id, 'limit', value)}
placeholder="Limit"
min={parseFloat(res.request)}
isDisabled={!customLimitsEnabled[res.id]}
aria-label={`${res.type || 'Custom resource'} limit`}
/>
</GridItem>
</Grid>
))}
<Button
style={{ width: 'fit-content' }}
variant="link"
icon={<PlusCircleIcon />}
onClick={handleAddCustom}
className="pf-u-mt-sm"
>
Add Custom Resource
</Button>
{requestRequestLargerThanLimit && (
<HelperText>
<HelperTextItem variant="error">
Resource limit should not be smaller than the request value
</HelperTextItem>
</HelperText>
)}
</FormFieldGroupExpandable>
);
};

View File

@ -6,6 +6,7 @@ export const AppRoutePaths = {
workspaceKinds: '/workspacekinds',
workspaceKindSummary: '/workspacekinds/:kind/summary',
workspaceKindCreate: '/workspacekinds/create',
workspaceKindEdit: '/workspacekinds/:kind/edit',
} satisfies Record<string, `/${string}`>;
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;
};

View File

@ -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;
}

View File

@ -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;

View File

@ -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 = (

View File

@ -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,