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:
parent
c6e81c2a77
commit
d680ea03fd
|
@ -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' },
|
||||
},
|
||||
};
|
|
@ -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 />} />
|
||||
{
|
||||
|
|
|
@ -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',
|
|
@ -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>
|
||||
|
|
|
@ -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: '',
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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(() => {
|
||||
|
|
|
@ -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,
|
||||
}));
|
||||
};
|
||||
|
|
|
@ -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 })}
|
||||
/>
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
};
|
|
@ -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>
|
||||
);
|
||||
};
|
|
@ -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>
|
||||
);
|
||||
};
|
|
@ -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>
|
||||
);
|
||||
};
|
|
@ -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;
|
||||
};
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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 = (
|
||||
|
|
|
@ -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,
|
||||
|
|
Loading…
Reference in New Issue