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.workspaceKindSummary} element={<WorkspaceKindSummaryWrapper />} />
|
||||||
<Route path={AppRoutePaths.workspaceKinds} element={<WorkspaceKinds />} />
|
<Route path={AppRoutePaths.workspaceKinds} element={<WorkspaceKinds />} />
|
||||||
<Route path={AppRoutePaths.workspaceKindCreate} element={<WorkspaceKindForm />} />
|
<Route path={AppRoutePaths.workspaceKindCreate} element={<WorkspaceKindForm />} />
|
||||||
|
<Route path={AppRoutePaths.workspaceKindEdit} element={<WorkspaceKindForm />} />
|
||||||
<Route path="/" element={<Navigate to={AppRoutePaths.workspaces} replace />} />
|
<Route path="/" element={<Navigate to={AppRoutePaths.workspaces} replace />} />
|
||||||
<Route path="*" element={<NotFound />} />
|
<Route path="*" element={<NotFound />} />
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -68,15 +68,12 @@ const EditableRow: React.FC<EditableRowInterface> = ({
|
||||||
|
|
||||||
type ColumnNames<T> = { [K in keyof T]: string };
|
type ColumnNames<T> = { [K in keyof T]: string };
|
||||||
|
|
||||||
interface WorkspaceKindFormLabelTableProps {
|
interface EditableLabelsProps {
|
||||||
rows: WorkspaceOptionLabel[];
|
rows: WorkspaceOptionLabel[];
|
||||||
setRows: (value: WorkspaceOptionLabel[]) => void;
|
setRows: (value: WorkspaceOptionLabel[]) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const WorkspaceKindFormLabelTable: React.FC<WorkspaceKindFormLabelTableProps> = ({
|
export const EditableLabels: React.FC<EditableLabelsProps> = ({ rows, setRows }) => {
|
||||||
rows,
|
|
||||||
setRows,
|
|
||||||
}) => {
|
|
||||||
const columnNames: ColumnNames<WorkspaceOptionLabel> = {
|
const columnNames: ColumnNames<WorkspaceOptionLabel> = {
|
||||||
key: 'Key',
|
key: 'Key',
|
||||||
value: 'Value',
|
value: 'Value',
|
||||||
|
|
@ -8,16 +8,16 @@ import {
|
||||||
PageGroup,
|
PageGroup,
|
||||||
PageSection,
|
PageSection,
|
||||||
Stack,
|
Stack,
|
||||||
ToggleGroup,
|
|
||||||
ToggleGroupItem,
|
|
||||||
} from '@patternfly/react-core';
|
} from '@patternfly/react-core';
|
||||||
import { useTypedNavigate } from '~/app/routerHelper';
|
import { useTypedNavigate } from '~/app/routerHelper';
|
||||||
|
import { useCurrentRouteKey } from '~/app/hooks/useCurrentRouteKey';
|
||||||
import useGenericObjectState from '~/app/hooks/useGenericObjectState';
|
import useGenericObjectState from '~/app/hooks/useGenericObjectState';
|
||||||
import { useNotebookAPI } from '~/app/hooks/useNotebookAPI';
|
import { useNotebookAPI } from '~/app/hooks/useNotebookAPI';
|
||||||
import { WorkspaceKindFormData } from '~/app/types';
|
import { WorkspaceKindFormData } from '~/app/types';
|
||||||
import { WorkspaceKindFileUpload } from './fileUpload/WorkspaceKindFileUpload';
|
import { WorkspaceKindFileUpload } from './fileUpload/WorkspaceKindFileUpload';
|
||||||
import { WorkspaceKindFormProperties } from './properties/WorkspaceKindFormProperties';
|
import { WorkspaceKindFormProperties } from './properties/WorkspaceKindFormProperties';
|
||||||
import { WorkspaceKindFormImage } from './image/WorkspaceKindFormImage';
|
import { WorkspaceKindFormImage } from './image/WorkspaceKindFormImage';
|
||||||
|
import { WorkspaceKindFormPodConfig } from './podConfig/WorkspaceKindFormPodConfig';
|
||||||
|
|
||||||
export enum WorkspaceKindFormView {
|
export enum WorkspaceKindFormView {
|
||||||
Form,
|
Form,
|
||||||
|
|
@ -30,13 +30,10 @@ export const WorkspaceKindForm: React.FC = () => {
|
||||||
const navigate = useTypedNavigate();
|
const navigate = useTypedNavigate();
|
||||||
const { api } = useNotebookAPI();
|
const { api } = useNotebookAPI();
|
||||||
// TODO: Detect mode by route
|
// TODO: Detect mode by route
|
||||||
const [mode] = useState('create');
|
|
||||||
const [yamlValue, setYamlValue] = useState('');
|
const [yamlValue, setYamlValue] = useState('');
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
const [view, setView] = useState<WorkspaceKindFormView>(WorkspaceKindFormView.FileUpload);
|
|
||||||
const [validated, setValidated] = useState<ValidationStatus>('default');
|
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>({
|
const [data, setData, resetData] = useGenericObjectState<WorkspaceKindFormData>({
|
||||||
properties: {
|
properties: {
|
||||||
displayName: '',
|
displayName: '',
|
||||||
|
|
@ -51,19 +48,11 @@ export const WorkspaceKindForm: React.FC = () => {
|
||||||
default: '',
|
default: '',
|
||||||
values: [],
|
values: [],
|
||||||
},
|
},
|
||||||
});
|
podConfig: {
|
||||||
|
default: '',
|
||||||
const handleViewClick = useCallback(
|
values: [],
|
||||||
(event: React.MouseEvent<unknown> | React.KeyboardEvent | MouseEvent) => {
|
|
||||||
const { id } = event.currentTarget as HTMLElement;
|
|
||||||
setView(
|
|
||||||
id === workspaceKindFileUploadId
|
|
||||||
? WorkspaceKindFormView.FileUpload
|
|
||||||
: WorkspaceKindFormView.Form,
|
|
||||||
);
|
|
||||||
},
|
},
|
||||||
[],
|
});
|
||||||
);
|
|
||||||
|
|
||||||
const handleSubmit = useCallback(async () => {
|
const handleSubmit = useCallback(async () => {
|
||||||
setIsSubmitting(true);
|
setIsSubmitting(true);
|
||||||
|
|
@ -101,39 +90,19 @@ export const WorkspaceKindForm: React.FC = () => {
|
||||||
{`${mode === 'create' ? 'Create' : 'Edit'} workspace kind`}
|
{`${mode === 'create' ? 'Create' : 'Edit'} workspace kind`}
|
||||||
</Content>
|
</Content>
|
||||||
<Content component={ContentVariants.p}>
|
<Content component={ContentVariants.p}>
|
||||||
{view === WorkspaceKindFormView.FileUpload
|
{mode === 'create'
|
||||||
? `Please upload or drag and drop a Workspace Kind YAML file.`
|
? `Please upload or drag and drop a Workspace Kind YAML file.`
|
||||||
: `View and edit the Workspace Kind's information. Some fields may not be
|
: `View and edit the Workspace Kind's information. Some fields may not be
|
||||||
represented in this form`}
|
represented in this form`}
|
||||||
</Content>
|
</Content>
|
||||||
</FlexItem>
|
</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>
|
</Flex>
|
||||||
</Stack>
|
</Stack>
|
||||||
</PageSection>
|
</PageSection>
|
||||||
</PageGroup>
|
</PageGroup>
|
||||||
<PageSection isFilled>
|
<PageSection isFilled>
|
||||||
{view === WorkspaceKindFormView.FileUpload && (
|
{mode === 'create' && (
|
||||||
<WorkspaceKindFileUpload
|
<WorkspaceKindFileUpload
|
||||||
setData={setData}
|
|
||||||
resetData={resetData}
|
resetData={resetData}
|
||||||
value={yamlValue}
|
value={yamlValue}
|
||||||
setValue={setYamlValue}
|
setValue={setYamlValue}
|
||||||
|
|
@ -141,7 +110,7 @@ export const WorkspaceKindForm: React.FC = () => {
|
||||||
setValidated={setValidated}
|
setValidated={setValidated}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{view === WorkspaceKindFormView.Form && (
|
{mode === 'edit' && (
|
||||||
<>
|
<>
|
||||||
<WorkspaceKindFormProperties
|
<WorkspaceKindFormProperties
|
||||||
mode={mode}
|
mode={mode}
|
||||||
|
|
@ -155,6 +124,12 @@ export const WorkspaceKindForm: React.FC = () => {
|
||||||
setData('imageConfig', imageInput);
|
setData('imageConfig', imageInput);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
<WorkspaceKindFormPodConfig
|
||||||
|
podConfig={data.podConfig}
|
||||||
|
updatePodConfig={(podConfig) => {
|
||||||
|
setData('podConfig', podConfig);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</PageSection>
|
</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,
|
HelperTextItem,
|
||||||
Content,
|
Content,
|
||||||
} from '@patternfly/react-core';
|
} from '@patternfly/react-core';
|
||||||
import { UpdateObjectAtPropAndValue } from '~/app/hooks/useGenericObjectState';
|
|
||||||
import { WorkspaceKindFormData } from '~/app/types';
|
|
||||||
import { isValidWorkspaceKindYaml } from '~/app/pages/WorkspaceKinds/Form/helpers';
|
import { isValidWorkspaceKindYaml } from '~/app/pages/WorkspaceKinds/Form/helpers';
|
||||||
import { ValidationStatus } from '~/app/pages/WorkspaceKinds/Form/WorkspaceKindForm';
|
import { ValidationStatus } from '~/app/pages/WorkspaceKinds/Form/WorkspaceKindForm';
|
||||||
|
|
||||||
interface WorkspaceKindFileUploadProps {
|
interface WorkspaceKindFileUploadProps {
|
||||||
setData: UpdateObjectAtPropAndValue<WorkspaceKindFormData>;
|
|
||||||
value: string;
|
value: string;
|
||||||
setValue: (v: string) => void;
|
setValue: (v: string) => void;
|
||||||
resetData: () => void;
|
resetData: () => void;
|
||||||
|
|
@ -23,7 +20,6 @@ interface WorkspaceKindFileUploadProps {
|
||||||
}
|
}
|
||||||
|
|
||||||
export const WorkspaceKindFileUpload: React.FC<WorkspaceKindFileUploadProps> = ({
|
export const WorkspaceKindFileUpload: React.FC<WorkspaceKindFileUploadProps> = ({
|
||||||
setData,
|
|
||||||
resetData,
|
resetData,
|
||||||
value,
|
value,
|
||||||
setValue,
|
setValue,
|
||||||
|
|
@ -62,30 +58,13 @@ export const WorkspaceKindFileUpload: React.FC<WorkspaceKindFileUploadProps> = (
|
||||||
if (isYamlFileRef.current) {
|
if (isYamlFileRef.current) {
|
||||||
try {
|
try {
|
||||||
const parsed = yaml.load(v);
|
const parsed = yaml.load(v);
|
||||||
if (isValidWorkspaceKindYaml(parsed)) {
|
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 {
|
|
||||||
setFileUploadHelperText('YAML is invalid: must follow WorkspaceKind format.');
|
setFileUploadHelperText('YAML is invalid: must follow WorkspaceKind format.');
|
||||||
setValidated('error');
|
setValidated('error');
|
||||||
resetData();
|
resetData();
|
||||||
|
} else {
|
||||||
|
setValidated('success');
|
||||||
|
setFileUploadHelperText('');
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('Error parsing YAML:', 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(() => {
|
const handleClear = useCallback(() => {
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,10 @@
|
||||||
import { ImagePullPolicy, WorkspaceKindImagePort } from '~/app/types';
|
import { ImagePullPolicy, WorkspaceKindImagePort, WorkspaceKindPodConfigValue } from '~/app/types';
|
||||||
import { WorkspaceOptionLabel } from '~/shared/api/backendApiTypes';
|
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
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/explicit-module-boundary-types
|
||||||
export const isValidWorkspaceKindYaml = (data: any): boolean => {
|
export const isValidWorkspaceKindYaml = (data: any): boolean => {
|
||||||
|
|
@ -88,3 +93,46 @@ export const emptyImage = {
|
||||||
to: '',
|
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,
|
HelperText,
|
||||||
} from '@patternfly/react-core';
|
} from '@patternfly/react-core';
|
||||||
import { WorkspaceKindImageConfigValue, ImagePullPolicy } from '~/app/types';
|
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 { emptyImage } from '~/app/pages/WorkspaceKinds/Form/helpers';
|
||||||
|
|
||||||
import { WorkspaceKindFormImageRedirect } from './WorkspaceKindFormImageRedirect';
|
import { WorkspaceKindFormImageRedirect } from './WorkspaceKindFormImageRedirect';
|
||||||
|
|
@ -100,7 +100,7 @@ export const WorkspaceKindFormImageModal: React.FC<WorkspaceKindFormImageModalPr
|
||||||
label={
|
label={
|
||||||
<div>
|
<div>
|
||||||
<div>Hidden</div>
|
<div>Hidden</div>
|
||||||
<HelperText>Hide this image from users </HelperText>
|
<HelperText>Hide this image from users</HelperText>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
aria-label="-controlled-check"
|
aria-label="-controlled-check"
|
||||||
|
|
@ -109,7 +109,7 @@ export const WorkspaceKindFormImageModal: React.FC<WorkspaceKindFormImageModalPr
|
||||||
name="workspace-kind-image-hidden-switch"
|
name="workspace-kind-image-hidden-switch"
|
||||||
/>
|
/>
|
||||||
</FormGroup>
|
</FormGroup>
|
||||||
<WorkspaceKindFormLabelTable
|
<EditableLabels
|
||||||
rows={image.labels}
|
rows={image.labels}
|
||||||
setRows={(labels) => setImage({ ...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',
|
workspaceKinds: '/workspacekinds',
|
||||||
workspaceKindSummary: '/workspacekinds/:kind/summary',
|
workspaceKindSummary: '/workspacekinds/:kind/summary',
|
||||||
workspaceKindCreate: '/workspacekinds/create',
|
workspaceKindCreate: '/workspacekinds/create',
|
||||||
|
workspaceKindEdit: '/workspacekinds/:kind/edit',
|
||||||
} satisfies Record<string, `/${string}`>;
|
} satisfies Record<string, `/${string}`>;
|
||||||
|
|
||||||
export type AppRoute = (typeof AppRoutePaths)[keyof typeof AppRoutePaths];
|
export type AppRoute = (typeof AppRoutePaths)[keyof typeof AppRoutePaths];
|
||||||
|
|
@ -31,6 +32,9 @@ export type RouteParamsMap = {
|
||||||
kind: string;
|
kind: string;
|
||||||
};
|
};
|
||||||
workspaceKindCreate: undefined;
|
workspaceKindCreate: undefined;
|
||||||
|
workspaceKindEdit: {
|
||||||
|
kind: string;
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -62,6 +66,9 @@ export type RouteStateMap = {
|
||||||
workspaceKindCreate: {
|
workspaceKindCreate: {
|
||||||
namespace: string;
|
namespace: string;
|
||||||
};
|
};
|
||||||
|
workspaceKindEdit: {
|
||||||
|
workspaceKindName: string;
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -82,4 +89,5 @@ export type RouteSearchParamsMap = {
|
||||||
workspaceKinds: undefined;
|
workspaceKinds: undefined;
|
||||||
workspaceKindSummary: undefined;
|
workspaceKindSummary: undefined;
|
||||||
workspaceKindCreate: 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
|
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 {
|
export interface WorkspaceKindImageConfigData {
|
||||||
default: string;
|
default: string;
|
||||||
values: WorkspaceKindImageConfigValue[];
|
values: WorkspaceKindImageConfigValue[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface WorkspaceKindPodConfigData {
|
||||||
|
default: string;
|
||||||
|
values: WorkspaceKindPodConfigValue[];
|
||||||
|
}
|
||||||
|
|
||||||
export interface WorkspaceKindFormData {
|
export interface WorkspaceKindFormData {
|
||||||
properties: WorkspaceKindProperties;
|
properties: WorkspaceKindProperties;
|
||||||
imageConfig: WorkspaceKindImageConfigData;
|
imageConfig: WorkspaceKindImageConfigData;
|
||||||
|
podConfig: WorkspaceKindPodConfigData;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -316,6 +316,22 @@
|
||||||
outline: none;
|
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 {
|
.mui-theme .pf-v6-c-text-input-group__text-input:focus {
|
||||||
--pf-v6-c-form-control--OutlineOffset: none;
|
--pf-v6-c-form-control--OutlineOffset: none;
|
||||||
outline: none;
|
outline: none;
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,20 @@ export enum YesNoValue {
|
||||||
No = 'No',
|
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 = (
|
export const extractResourceValue = (
|
||||||
workspace: Workspace,
|
workspace: Workspace,
|
||||||
resourceType: ResourceType,
|
resourceType: ResourceType,
|
||||||
|
|
@ -24,18 +38,13 @@ export const formatResourceValue = (v: string | undefined, resourceType?: Resour
|
||||||
if (v === undefined) {
|
if (v === undefined) {
|
||||||
return '-';
|
return '-';
|
||||||
}
|
}
|
||||||
switch (resourceType) {
|
|
||||||
case 'cpu': {
|
if (!resourceType) {
|
||||||
const [cpuValue, cpuUnit] = splitValueUnit(v, CPU_UNITS);
|
return v;
|
||||||
return `${cpuValue ?? ''} ${cpuUnit.name}`;
|
|
||||||
}
|
|
||||||
case 'memory': {
|
|
||||||
const [memoryValue, memoryUnit] = splitValueUnit(v, MEMORY_UNITS_FOR_PARSING);
|
|
||||||
return `${memoryValue ?? ''} ${memoryUnit.name}`;
|
|
||||||
}
|
|
||||||
default:
|
|
||||||
return v;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const [value, unit] = parseResourceValue(v, resourceType);
|
||||||
|
return `${value || ''} ${unit?.name || ''}`.trim();
|
||||||
};
|
};
|
||||||
|
|
||||||
export const formatResourceFromWorkspace = (
|
export const formatResourceFromWorkspace = (
|
||||||
|
|
|
||||||
|
|
@ -19,7 +19,7 @@ export type UnitOption = {
|
||||||
|
|
||||||
export const CPU_UNITS: UnitOption[] = [
|
export const CPU_UNITS: UnitOption[] = [
|
||||||
{ name: 'Cores', unit: '', weight: 1000 },
|
{ name: 'Cores', unit: '', weight: 1000 },
|
||||||
{ name: 'Milicores', unit: 'm', weight: 1 },
|
{ name: 'Millicores', unit: 'm', weight: 1 },
|
||||||
];
|
];
|
||||||
export const MEMORY_UNITS_FOR_SELECTION: UnitOption[] = [
|
export const MEMORY_UNITS_FOR_SELECTION: UnitOption[] = [
|
||||||
{ name: 'GiB', unit: 'Gi', weight: 1024 },
|
{ name: 'GiB', unit: 'Gi', weight: 1024 },
|
||||||
|
|
@ -40,7 +40,7 @@ export const MEMORY_UNITS_FOR_PARSING: UnitOption[] = [
|
||||||
{ name: 'KiB', unit: 'Ki', weight: 1024 },
|
{ name: 'KiB', unit: 'Ki', weight: 1024 },
|
||||||
{ name: 'B', unit: '', weight: 1 },
|
{ 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 = (
|
export const splitValueUnit = (
|
||||||
value: ValueUnitString,
|
value: ValueUnitString,
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue