Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions workspaces/frontend/src/__mocks__/mockResources.ts
Original file line number Diff line number Diff line change
@@ -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' },
},
};
1 change: 1 addition & 0 deletions workspaces/frontend/src/app/AppRoutes.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 />} />
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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: '',
Expand All @@ -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);
Expand Down Expand Up @@ -101,47 +90,27 @@ 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}
validated={validated}
setValidated={setValidated}
/>
)}
{view === WorkspaceKindFormView.Form && (
{mode === 'edit' && (
<>
<WorkspaceKindFormProperties
mode={mode}
Expand All @@ -155,6 +124,12 @@ export const WorkspaceKindForm: React.FC = () => {
setData('imageConfig', imageInput);
}}
/>
<WorkspaceKindFormPodConfig
podConfig={data.podConfig}
updatePodConfig={(podConfig) => {
setData('podConfig', podConfig);
}}
/>
</>
)}
</PageSection>
Expand Down
Original file line number Diff line number Diff line change
@@ -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: '',
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -23,7 +20,6 @@ interface WorkspaceKindFileUploadProps {
}

export const WorkspaceKindFileUpload: React.FC<WorkspaceKindFileUploadProps> = ({
setData,
resetData,
value,
setValue,
Expand Down Expand Up @@ -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);
Expand All @@ -94,7 +73,7 @@ export const WorkspaceKindFileUpload: React.FC<WorkspaceKindFileUploadProps> = (
}
}
},
[setValue, setData, setValidated, resetData],
[setValue, setValidated, resetData],
);

const handleClear = useCallback(() => {
Expand Down
52 changes: 50 additions & 2 deletions workspaces/frontend/src/app/pages/WorkspaceKinds/Form/helpers.ts
Original file line number Diff line number Diff line change
@@ -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 => {
Expand Down Expand Up @@ -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,
}));
};
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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"
Expand All @@ -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 })}
/>
Expand Down
Loading