Skip to content

Commit e3a9c74

Browse files
authored
feat: workspace kind Edit Pod Configs (#425)
* Add Pod Config to WorkspaceKind form Signed-off-by: Charles Thao <[email protected]> * Add resource section for PodConfig Signed-off-by: Charles Thao <[email protected]> * Use refactored types Signed-off-by: Charles Thao <[email protected]> * Improve Resource input Signed-off-by: Charles Thao <[email protected]> * Move form view to edit mode only Signed-off-by: Charles Thao <[email protected]> * Bug fix and improvements Signed-off-by: Charles Thao <[email protected]> --------- Signed-off-by: Charles Thao <[email protected]>
1 parent 063d533 commit e3a9c74

File tree

17 files changed

+1178
-90
lines changed

17 files changed

+1178
-90
lines changed
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
import { WorkspaceKindPodConfigValue } from '~/app/types';
2+
3+
export const mockPodConfig: WorkspaceKindPodConfigValue = {
4+
id: 'pod_config_35',
5+
displayName: '8000m CPU, 2Gi RAM, 1 GPU',
6+
description: 'Pod with 8000m CPU, 2Gi RAM, and 1 GPU',
7+
labels: [
8+
{ key: 'cpu', value: '8000m' },
9+
{ key: 'memory', value: '2Gi' },
10+
],
11+
hidden: false,
12+
resources: {
13+
requests: { cpu: '8000m', memory: '2Gi' },
14+
limits: { 'nvidia.com/gpu': '2' },
15+
},
16+
};

workspaces/frontend/src/app/AppRoutes.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,7 @@ const AppRoutes: React.FC = () => {
6868
<Route path={AppRoutePaths.workspaceKindSummary} element={<WorkspaceKindSummaryWrapper />} />
6969
<Route path={AppRoutePaths.workspaceKinds} element={<WorkspaceKinds />} />
7070
<Route path={AppRoutePaths.workspaceKindCreate} element={<WorkspaceKindForm />} />
71+
<Route path={AppRoutePaths.workspaceKindEdit} element={<WorkspaceKindForm />} />
7172
<Route path="/" element={<Navigate to={AppRoutePaths.workspaces} replace />} />
7273
<Route path="*" element={<NotFound />} />
7374
{
Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -68,15 +68,12 @@ const EditableRow: React.FC<EditableRowInterface> = ({
6868

6969
type ColumnNames<T> = { [K in keyof T]: string };
7070

71-
interface WorkspaceKindFormLabelTableProps {
71+
interface EditableLabelsProps {
7272
rows: WorkspaceOptionLabel[];
7373
setRows: (value: WorkspaceOptionLabel[]) => void;
7474
}
7575

76-
export const WorkspaceKindFormLabelTable: React.FC<WorkspaceKindFormLabelTableProps> = ({
77-
rows,
78-
setRows,
79-
}) => {
76+
export const EditableLabels: React.FC<EditableLabelsProps> = ({ rows, setRows }) => {
8077
const columnNames: ColumnNames<WorkspaceOptionLabel> = {
8178
key: 'Key',
8279
value: 'Value',

workspaces/frontend/src/app/pages/WorkspaceKinds/Form/WorkspaceKindForm.tsx

Lines changed: 16 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -8,16 +8,16 @@ import {
88
PageGroup,
99
PageSection,
1010
Stack,
11-
ToggleGroup,
12-
ToggleGroupItem,
1311
} from '@patternfly/react-core';
1412
import { useTypedNavigate } from '~/app/routerHelper';
13+
import { useCurrentRouteKey } from '~/app/hooks/useCurrentRouteKey';
1514
import useGenericObjectState from '~/app/hooks/useGenericObjectState';
1615
import { useNotebookAPI } from '~/app/hooks/useNotebookAPI';
1716
import { WorkspaceKindFormData } from '~/app/types';
1817
import { WorkspaceKindFileUpload } from './fileUpload/WorkspaceKindFileUpload';
1918
import { WorkspaceKindFormProperties } from './properties/WorkspaceKindFormProperties';
2019
import { WorkspaceKindFormImage } from './image/WorkspaceKindFormImage';
20+
import { WorkspaceKindFormPodConfig } from './podConfig/WorkspaceKindFormPodConfig';
2121

2222
export enum WorkspaceKindFormView {
2323
Form,
@@ -30,13 +30,10 @@ export const WorkspaceKindForm: React.FC = () => {
3030
const navigate = useTypedNavigate();
3131
const { api } = useNotebookAPI();
3232
// TODO: Detect mode by route
33-
const [mode] = useState('create');
3433
const [yamlValue, setYamlValue] = useState('');
3534
const [isSubmitting, setIsSubmitting] = useState(false);
36-
const [view, setView] = useState<WorkspaceKindFormView>(WorkspaceKindFormView.FileUpload);
3735
const [validated, setValidated] = useState<ValidationStatus>('default');
38-
const workspaceKindFileUploadId = 'workspace-kind-form-fileupload-view';
39-
36+
const mode = useCurrentRouteKey() === 'workspaceKindCreate' ? 'create' : 'edit';
4037
const [data, setData, resetData] = useGenericObjectState<WorkspaceKindFormData>({
4138
properties: {
4239
displayName: '',
@@ -51,19 +48,11 @@ export const WorkspaceKindForm: React.FC = () => {
5148
default: '',
5249
values: [],
5350
},
54-
});
55-
56-
const handleViewClick = useCallback(
57-
(event: React.MouseEvent<unknown> | React.KeyboardEvent | MouseEvent) => {
58-
const { id } = event.currentTarget as HTMLElement;
59-
setView(
60-
id === workspaceKindFileUploadId
61-
? WorkspaceKindFormView.FileUpload
62-
: WorkspaceKindFormView.Form,
63-
);
51+
podConfig: {
52+
default: '',
53+
values: [],
6454
},
65-
[],
66-
);
55+
});
6756

6857
const handleSubmit = useCallback(async () => {
6958
setIsSubmitting(true);
@@ -101,47 +90,27 @@ export const WorkspaceKindForm: React.FC = () => {
10190
{`${mode === 'create' ? 'Create' : 'Edit'} workspace kind`}
10291
</Content>
10392
<Content component={ContentVariants.p}>
104-
{view === WorkspaceKindFormView.FileUpload
93+
{mode === 'create'
10594
? `Please upload or drag and drop a Workspace Kind YAML file.`
10695
: `View and edit the Workspace Kind's information. Some fields may not be
10796
represented in this form`}
10897
</Content>
10998
</FlexItem>
110-
{mode === 'edit' && (
111-
<FlexItem>
112-
<ToggleGroup className="workspace-kind-form-header" aria-label="Toggle form view">
113-
<ToggleGroupItem
114-
text="YAML Upload"
115-
buttonId={workspaceKindFileUploadId}
116-
isSelected={view === WorkspaceKindFormView.FileUpload}
117-
onChange={handleViewClick}
118-
/>
119-
<ToggleGroupItem
120-
text="Form View"
121-
buttonId="workspace-kind-form-form-view"
122-
isSelected={view === WorkspaceKindFormView.Form}
123-
onChange={handleViewClick}
124-
isDisabled={yamlValue === '' || validated === 'error'}
125-
/>
126-
</ToggleGroup>
127-
</FlexItem>
128-
)}
12999
</Flex>
130100
</Stack>
131101
</PageSection>
132102
</PageGroup>
133103
<PageSection isFilled>
134-
{view === WorkspaceKindFormView.FileUpload && (
104+
{mode === 'create' && (
135105
<WorkspaceKindFileUpload
136-
setData={setData}
137106
resetData={resetData}
138107
value={yamlValue}
139108
setValue={setYamlValue}
140109
validated={validated}
141110
setValidated={setValidated}
142111
/>
143112
)}
144-
{view === WorkspaceKindFormView.Form && (
113+
{mode === 'edit' && (
145114
<>
146115
<WorkspaceKindFormProperties
147116
mode={mode}
@@ -155,6 +124,12 @@ export const WorkspaceKindForm: React.FC = () => {
155124
setData('imageConfig', imageInput);
156125
}}
157126
/>
127+
<WorkspaceKindFormPodConfig
128+
podConfig={data.podConfig}
129+
updatePodConfig={(podConfig) => {
130+
setData('podConfig', podConfig);
131+
}}
132+
/>
158133
</>
159134
)}
160135
</PageSection>
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
import { getResources } from '~/app/pages/WorkspaceKinds/Form/helpers';
2+
import { mockPodConfig } from '~/__mocks__/mockResources';
3+
import { WorkspaceKindPodConfigValue } from '~/app/types';
4+
5+
describe('getResources', () => {
6+
it('should convert k8s resource object to PodResourceEntry array with correct structure', () => {
7+
const result = getResources(mockPodConfig);
8+
expect(result).toHaveLength(3);
9+
10+
const cpu = result.find((r) => r.type === 'cpu');
11+
expect(cpu).toBeDefined();
12+
expect(cpu).toEqual({
13+
id: 'cpu-resource',
14+
type: 'cpu',
15+
request: '8000m',
16+
limit: '',
17+
});
18+
19+
const memory = result.find((r) => r.type === 'memory');
20+
expect(memory).toBeDefined();
21+
expect(memory).toEqual({
22+
id: 'memory-resource',
23+
type: 'memory',
24+
request: '2Gi',
25+
limit: '',
26+
});
27+
28+
// Check custom GPU resource
29+
const gpu = result.find((r) => r.type === 'nvidia.com/gpu');
30+
expect(gpu).toBeDefined();
31+
expect(gpu?.type).toBe('nvidia.com/gpu');
32+
expect(gpu?.request).toBe('');
33+
expect(gpu?.limit).toBe('2');
34+
expect(gpu?.id).toMatch(/nvidia\.com\/gpu-/);
35+
});
36+
37+
it(' handle empty or missing resources and return default CPU and memory entries', () => {
38+
const emptyConfig: WorkspaceKindPodConfigValue = {
39+
id: 'test-config',
40+
displayName: 'Test Config',
41+
description: 'Test Description',
42+
labels: [],
43+
hidden: false,
44+
};
45+
46+
const result = getResources(emptyConfig);
47+
48+
// Should return CPU and memory with empty values
49+
expect(result).toHaveLength(2);
50+
51+
const cpu = result.find((r) => r.type === 'cpu');
52+
expect(cpu).toEqual({
53+
id: 'cpu-resource',
54+
type: 'cpu',
55+
request: '',
56+
limit: '',
57+
});
58+
59+
const memory = result.find((r) => r.type === 'memory');
60+
expect(memory).toEqual({
61+
id: 'memory-resource',
62+
type: 'memory',
63+
request: '',
64+
limit: '',
65+
});
66+
});
67+
});

workspaces/frontend/src/app/pages/WorkspaceKinds/Form/fileUpload/WorkspaceKindFileUpload.tsx

Lines changed: 5 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -8,13 +8,10 @@ import {
88
HelperTextItem,
99
Content,
1010
} from '@patternfly/react-core';
11-
import { UpdateObjectAtPropAndValue } from '~/app/hooks/useGenericObjectState';
12-
import { WorkspaceKindFormData } from '~/app/types';
1311
import { isValidWorkspaceKindYaml } from '~/app/pages/WorkspaceKinds/Form/helpers';
1412
import { ValidationStatus } from '~/app/pages/WorkspaceKinds/Form/WorkspaceKindForm';
1513

1614
interface WorkspaceKindFileUploadProps {
17-
setData: UpdateObjectAtPropAndValue<WorkspaceKindFormData>;
1815
value: string;
1916
setValue: (v: string) => void;
2017
resetData: () => void;
@@ -23,7 +20,6 @@ interface WorkspaceKindFileUploadProps {
2320
}
2421

2522
export const WorkspaceKindFileUpload: React.FC<WorkspaceKindFileUploadProps> = ({
26-
setData,
2723
resetData,
2824
value,
2925
setValue,
@@ -62,30 +58,13 @@ export const WorkspaceKindFileUpload: React.FC<WorkspaceKindFileUploadProps> = (
6258
if (isYamlFileRef.current) {
6359
try {
6460
const parsed = yaml.load(v);
65-
if (isValidWorkspaceKindYaml(parsed)) {
66-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
67-
setData('properties', (parsed as any).spec.spawner);
68-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
69-
const parsedImg = (parsed as any).spec.podTemplate.options.imageConfig;
70-
setData('imageConfig', {
71-
default: parsedImg.spawner.default || '',
72-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
73-
values: parsedImg.values.map((img: any) => {
74-
const res = {
75-
id: img.id,
76-
redirect: img.redirect,
77-
...img.spawner,
78-
...img.spec,
79-
};
80-
return res;
81-
}),
82-
});
83-
setValidated('success');
84-
setFileUploadHelperText('');
85-
} else {
61+
if (!isValidWorkspaceKindYaml(parsed)) {
8662
setFileUploadHelperText('YAML is invalid: must follow WorkspaceKind format.');
8763
setValidated('error');
8864
resetData();
65+
} else {
66+
setValidated('success');
67+
setFileUploadHelperText('');
8968
}
9069
} catch (e) {
9170
console.error('Error parsing YAML:', e);
@@ -94,7 +73,7 @@ export const WorkspaceKindFileUpload: React.FC<WorkspaceKindFileUploadProps> = (
9473
}
9574
}
9675
},
97-
[setValue, setData, setValidated, resetData],
76+
[setValue, setValidated, resetData],
9877
);
9978

10079
const handleClear = useCallback(() => {

workspaces/frontend/src/app/pages/WorkspaceKinds/Form/helpers.ts

Lines changed: 50 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,10 @@
1-
import { ImagePullPolicy, WorkspaceKindImagePort } from '~/app/types';
2-
import { WorkspaceOptionLabel } from '~/shared/api/backendApiTypes';
1+
import { ImagePullPolicy, WorkspaceKindImagePort, WorkspaceKindPodConfigValue } from '~/app/types';
2+
import { WorkspaceOptionLabel, WorkspacePodConfigValue } from '~/shared/api/backendApiTypes';
3+
import { PodResourceEntry } from './podConfig/WorkspaceKindFormResource';
4+
5+
// Simple ID generator to avoid PatternFly dependency in tests
6+
export const generateUniqueId = (): string =>
7+
`id-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
38

49
// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/explicit-module-boundary-types
510
export const isValidWorkspaceKindYaml = (data: any): boolean => {
@@ -88,3 +93,46 @@ export const emptyImage = {
8893
to: '',
8994
},
9095
};
96+
97+
export const emptyPodConfig: WorkspacePodConfigValue = {
98+
id: '',
99+
displayName: '',
100+
description: '',
101+
labels: [],
102+
hidden: false,
103+
redirect: {
104+
to: '',
105+
},
106+
};
107+
// 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)
108+
export const getResources = (currConfig: WorkspaceKindPodConfigValue): PodResourceEntry[] => {
109+
const grouped = new Map<string, { request: string; limit: string }>([
110+
['cpu', { request: '', limit: '' }],
111+
['memory', { request: '', limit: '' }],
112+
]);
113+
const { requests = {}, limits = {} } = currConfig.resources || {};
114+
const types = new Set([...Object.keys(requests), ...Object.keys(limits), 'cpu', 'memory']);
115+
types.forEach((type) => {
116+
const entry = grouped.get(type) || { request: '', limit: '' };
117+
if (type in requests) {
118+
entry.request = String(requests[type]);
119+
}
120+
if (type in limits) {
121+
entry.limit = String(limits[type]);
122+
}
123+
grouped.set(type, entry);
124+
});
125+
126+
// Convert to UI-types with consistent IDs
127+
return Array.from(grouped.entries()).map(([type, { request, limit }]) => ({
128+
id:
129+
type === 'cpu'
130+
? 'cpu-resource'
131+
: type === 'memory'
132+
? 'memory-resource'
133+
: `${type}-${generateUniqueId()}`,
134+
type,
135+
request,
136+
limit,
137+
}));
138+
};

workspaces/frontend/src/app/pages/WorkspaceKinds/Form/image/WorkspaceKindFormImageModal.tsx

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ import {
1414
HelperText,
1515
} from '@patternfly/react-core';
1616
import { WorkspaceKindImageConfigValue, ImagePullPolicy } from '~/app/types';
17-
import { WorkspaceKindFormLabelTable } from '~/app/pages/WorkspaceKinds/Form/WorkspaceKindFormLabels';
17+
import { EditableLabels } from '~/app/pages/WorkspaceKinds/Form/EditableLabels';
1818
import { emptyImage } from '~/app/pages/WorkspaceKinds/Form/helpers';
1919

2020
import { WorkspaceKindFormImageRedirect } from './WorkspaceKindFormImageRedirect';
@@ -100,7 +100,7 @@ export const WorkspaceKindFormImageModal: React.FC<WorkspaceKindFormImageModalPr
100100
label={
101101
<div>
102102
<div>Hidden</div>
103-
<HelperText>Hide this image from users </HelperText>
103+
<HelperText>Hide this image from users</HelperText>
104104
</div>
105105
}
106106
aria-label="-controlled-check"
@@ -109,7 +109,7 @@ export const WorkspaceKindFormImageModal: React.FC<WorkspaceKindFormImageModalPr
109109
name="workspace-kind-image-hidden-switch"
110110
/>
111111
</FormGroup>
112-
<WorkspaceKindFormLabelTable
112+
<EditableLabels
113113
rows={image.labels}
114114
setRows={(labels) => setImage({ ...image, labels })}
115115
/>

0 commit comments

Comments
 (0)