feat(ws): Add advanced pod configurations in Workspace Edit (#468)

Signed-off-by: Charles Thao <cthao@redhat.com>
This commit is contained in:
Charles Thao 2025-07-10 12:49:53 -04:00 committed by Bhakti Narvekar
parent 5e9c88f582
commit 0e90e5da65
18 changed files with 639 additions and 206 deletions

View File

@ -1,10 +1,11 @@
import React from 'react'; import React from 'react';
import { Alert, List, ListItem } from '@patternfly/react-core'; import { Alert, List, ListItem } from '@patternfly/react-core';
import { ValidationError } from '~/shared/api/backendApiTypes'; import { ValidationError } from '~/shared/api/backendApiTypes';
import { ErrorEnvelopeException } from '~/shared/api/apiUtils';
interface ValidationErrorAlertProps { interface ValidationErrorAlertProps {
title: string; title: string;
errors: ValidationError[]; errors: (ValidationError | ErrorEnvelopeException)[];
} }
export const ValidationErrorAlert: React.FC<ValidationErrorAlertProps> = ({ title, errors }) => { export const ValidationErrorAlert: React.FC<ValidationErrorAlertProps> = ({ title, errors }) => {

View File

@ -71,9 +71,18 @@ type ColumnNames<T> = { [K in keyof T]: string };
interface EditableLabelsProps { interface EditableLabelsProps {
rows: WorkspaceOptionLabel[]; rows: WorkspaceOptionLabel[];
setRows: (value: WorkspaceOptionLabel[]) => void; setRows: (value: WorkspaceOptionLabel[]) => void;
title?: string;
description?: string;
buttonLabel?: string;
} }
export const EditableLabels: React.FC<EditableLabelsProps> = ({ rows, setRows }) => { export const EditableLabels: React.FC<EditableLabelsProps> = ({
rows,
setRows,
title = 'Labels',
description,
buttonLabel = 'Label',
}) => {
const columnNames: ColumnNames<WorkspaceOptionLabel> = { const columnNames: ColumnNames<WorkspaceOptionLabel> = {
key: 'Key', key: 'Key',
value: 'Value', value: 'Value',
@ -86,12 +95,15 @@ export const EditableLabels: React.FC<EditableLabelsProps> = ({ rows, setRows })
header={ header={
<FormFieldGroupHeader <FormFieldGroupHeader
titleText={{ titleText={{
text: 'Labels', text: title,
id: 'workspace-kind-image-ports', id: `${title}-labels`,
}} }}
titleDescription={ titleDescription={
<> <>
<div>Labels are key/value pairs that are attached to Kubernetes objects.</div> <div>
{description ||
'Labels are key/value pairs that are attached to Kubernetes objects.'}
</div>
<div className="pf-u-font-size-sm"> <div className="pf-u-font-size-sm">
<strong>{rows.length} added</strong> <strong>{rows.length} added</strong>
</div> </div>
@ -141,7 +153,7 @@ export const EditableLabels: React.FC<EditableLabelsProps> = ({ rows, setRows })
]); ]);
}} }}
> >
Add Label {`Add ${buttonLabel}`}
</Button> </Button>
</FormFieldGroupExpandable> </FormFieldGroupExpandable>
); );

View File

@ -1,8 +1,10 @@
import React, { useCallback, useMemo, useState } from 'react'; import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { import {
Button, Button,
Content, Content,
ContentVariants, ContentVariants,
EmptyState,
EmptyStateBody,
Flex, Flex,
FlexItem, FlexItem,
PageGroup, PageGroup,
@ -11,18 +13,22 @@ import {
StackItem, StackItem,
} from '@patternfly/react-core'; } from '@patternfly/react-core';
import { t_global_spacer_sm as SmallPadding } from '@patternfly/react-tokens'; import { t_global_spacer_sm as SmallPadding } from '@patternfly/react-tokens';
import { ExclamationCircleIcon } from '@patternfly/react-icons';
import { ValidationErrorAlert } from '~/app/components/ValidationErrorAlert'; import { ValidationErrorAlert } from '~/app/components/ValidationErrorAlert';
import { useTypedNavigate } from '~/app/routerHelper'; import useWorkspaceKindByName from '~/app/hooks/useWorkspaceKindByName';
import { WorkspaceKind, ValidationError } from '~/shared/api/backendApiTypes';
import { useTypedNavigate, useTypedParams } from '~/app/routerHelper';
import { useCurrentRouteKey } from '~/app/hooks/useCurrentRouteKey'; 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 { ErrorEnvelopeException } from '~/shared/api/apiUtils'; import { ErrorEnvelopeException } from '~/shared/api/apiUtils';
import { ValidationError } from '~/shared/api/backendApiTypes';
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'; import { WorkspaceKindFormPodConfig } from './podConfig/WorkspaceKindFormPodConfig';
import { WorkspaceKindFormPodTemplate } from './podTemplate/WorkspaceKindFormPodTemplate';
import { EMPTY_WORKSPACE_KIND_FORM_DATA } from './helpers';
export enum WorkspaceKindFormView { export enum WorkspaceKindFormView {
Form, Form,
@ -30,6 +36,19 @@ export enum WorkspaceKindFormView {
} }
export type ValidationStatus = 'success' | 'error' | 'default'; export type ValidationStatus = 'success' | 'error' | 'default';
export type FormMode = 'edit' | 'create';
const convertToFormData = (initialData: WorkspaceKind): WorkspaceKindFormData => {
const { podTemplate, ...properties } = initialData;
const { options, ...spec } = podTemplate;
const { podConfig, imageConfig } = options;
return {
properties,
podConfig,
imageConfig,
podTemplate: spec,
};
};
export const WorkspaceKindForm: React.FC = () => { export const WorkspaceKindForm: React.FC = () => {
const navigate = useTypedNavigate(); const navigate = useTypedNavigate();
@ -38,28 +57,23 @@ export const WorkspaceKindForm: React.FC = () => {
const [yamlValue, setYamlValue] = useState(''); const [yamlValue, setYamlValue] = useState('');
const [isSubmitting, setIsSubmitting] = useState(false); const [isSubmitting, setIsSubmitting] = useState(false);
const [validated, setValidated] = useState<ValidationStatus>('default'); const [validated, setValidated] = useState<ValidationStatus>('default');
const mode = useCurrentRouteKey() === 'workspaceKindCreate' ? 'create' : 'edit'; const mode: FormMode = useCurrentRouteKey() === 'workspaceKindCreate' ? 'create' : 'edit';
const [specErrors, setSpecErrors] = useState<ValidationError[]>([]); const [specErrors, setSpecErrors] = useState<(ValidationError | ErrorEnvelopeException)[]>([]);
const [data, setData, resetData] = useGenericObjectState<WorkspaceKindFormData>({ const { kind } = useTypedParams<'workspaceKindEdit'>();
properties: { const [initialFormData, initialFormDataLoaded, initialFormDataError] =
displayName: '', useWorkspaceKindByName(kind);
description: '',
deprecated: false, const [data, setData, resetData, replaceData] = useGenericObjectState<WorkspaceKindFormData>(
deprecationMessage: '', initialFormData ? convertToFormData(initialFormData) : EMPTY_WORKSPACE_KIND_FORM_DATA,
hidden: false, );
icon: { url: '' },
logo: { url: '' }, useEffect(() => {
}, if (!initialFormDataLoaded || initialFormData === null || mode === 'create') {
imageConfig: { return;
default: '', }
values: [], replaceData(convertToFormData(initialFormData));
}, }, [initialFormData, initialFormDataLoaded, mode, replaceData]);
podConfig: {
default: '',
values: [],
},
});
const handleSubmit = useCallback(async () => { const handleSubmit = useCallback(async () => {
setIsSubmitting(true); setIsSubmitting(true);
@ -71,14 +85,20 @@ export const WorkspaceKindForm: React.FC = () => {
console.info('New workspace kind created:', JSON.stringify(newWorkspaceKind)); console.info('New workspace kind created:', JSON.stringify(newWorkspaceKind));
navigate('workspaceKinds'); navigate('workspaceKinds');
} }
// TODO: Finish when WSKind API is finalized
// const updatedWorkspace = await api.updateWorkspaceKind({}, kind, { data: {} });
// console.info('Workspace Kind updated:', JSON.stringify(updatedWorkspace));
// navigate('workspaceKinds');
} catch (err) { } catch (err) {
if (err instanceof ErrorEnvelopeException) { if (err instanceof ErrorEnvelopeException) {
const validationErrors = err.envelope.error?.cause?.validation_errors; const validationErrors = err.envelope.error?.cause?.validation_errors;
if (validationErrors && validationErrors.length > 0) { if (validationErrors && validationErrors.length > 0) {
setSpecErrors(validationErrors); setSpecErrors((prev) => [...prev, ...validationErrors]);
setValidated('error'); setValidated('error');
return; return;
} }
setSpecErrors((prev) => [...prev, err]);
setValidated('error');
} }
// TODO: alert user about error // TODO: alert user about error
console.error(`Error ${mode === 'edit' ? 'editing' : 'creating'} workspace kind: ${err}`); console.error(`Error ${mode === 'edit' ? 'editing' : 'creating'} workspace kind: ${err}`);
@ -88,14 +108,26 @@ export const WorkspaceKindForm: React.FC = () => {
}, [navigate, mode, api, yamlValue]); }, [navigate, mode, api, yamlValue]);
const canSubmit = useMemo( const canSubmit = useMemo(
() => !isSubmitting && yamlValue.length > 0 && validated === 'success', () => !isSubmitting && validated === 'success',
[yamlValue, isSubmitting, validated], [isSubmitting, validated],
); );
const cancel = useCallback(() => { const cancel = useCallback(() => {
navigate('workspaceKinds'); navigate('workspaceKinds');
}, [navigate]); }, [navigate]);
if (mode === 'edit' && initialFormDataError) {
return (
<EmptyState
titleText="Error loading Workspace Kind data"
headingLevel="h4"
icon={ExclamationCircleIcon}
status="danger"
>
<EmptyStateBody>{initialFormDataError.message}</EmptyStateBody>
</EmptyState>
);
}
return ( return (
<> <>
<PageGroup isFilled={false} stickyOnBreakpoint={{ default: 'top' }}> <PageGroup isFilled={false} stickyOnBreakpoint={{ default: 'top' }}>
@ -159,6 +191,12 @@ export const WorkspaceKindForm: React.FC = () => {
setData('podConfig', podConfig); setData('podConfig', podConfig);
}} }}
/> />
<WorkspaceKindFormPodTemplate
podTemplate={data.podTemplate}
updatePodTemplate={(podTemplate) => {
setData('podTemplate', podTemplate);
}}
/>
</> </>
)} )}
</PageSection> </PageSection>
@ -169,9 +207,10 @@ export const WorkspaceKindForm: React.FC = () => {
variant="primary" variant="primary"
ouiaId="Primary" ouiaId="Primary"
onClick={handleSubmit} onClick={handleSubmit}
isDisabled={!canSubmit} // TODO: button is always disabled on edit mode. Need to modify when WorkspaceKind edit is finalized
isDisabled={!canSubmit || mode === 'edit'}
> >
{mode === 'create' ? 'Create' : 'Edit'} {mode === 'create' ? 'Create' : 'Save'}
</Button> </Button>
</FlexItem> </FlexItem>
<FlexItem> <FlexItem>

View File

@ -0,0 +1,142 @@
import React, { useMemo, useState } from 'react';
import { Table, Thead, Tr, Td, Tbody, Th } from '@patternfly/react-table';
import {
Dropdown,
DropdownItem,
getUniqueId,
Label,
MenuToggle,
PageSection,
Pagination,
PaginationVariant,
Radio,
} from '@patternfly/react-core';
import { EllipsisVIcon } from '@patternfly/react-icons';
import { WorkspaceKindImageConfigValue } from '~/app/types';
import { WorkspacePodConfigValue } from '~/shared/api/backendApiTypes';
interface PaginatedTableProps {
rows: WorkspaceKindImageConfigValue[] | WorkspacePodConfigValue[];
defaultId: string;
setDefaultId: (id: string) => void;
handleEdit: (index: number) => void;
openDeleteModal: (index: number) => void;
ariaLabel: string;
}
export const WorkspaceKindFormPaginatedTable: React.FC<PaginatedTableProps> = ({
rows,
defaultId,
setDefaultId,
handleEdit,
openDeleteModal,
ariaLabel,
}) => {
const [dropdownOpen, setDropdownOpen] = useState<number | null>(null);
const [page, setPage] = useState(1);
const [perPage, setPerPage] = useState(10);
const rowPages = useMemo(() => {
const pages = [];
for (let i = 0; i < rows.length; i += perPage) {
pages.push(rows.slice(i, i + perPage));
}
return pages;
}, [perPage, rows]);
const onSetPage = (
_event: React.MouseEvent | React.KeyboardEvent | MouseEvent,
newPage: number,
) => {
setPage(newPage);
};
const onPerPageSelect = (
_event: React.MouseEvent | React.KeyboardEvent | MouseEvent,
newPerPage: number,
newPage: number,
) => {
setPerPage(newPerPage);
setPage(newPage);
};
return (
<PageSection>
<Table aria-label={ariaLabel}>
<Thead>
<Tr>
<Th>Display Name</Th>
<Th>ID</Th>
<Th screenReaderText="Row select">Default</Th>
<Th>Labels</Th>
<Th aria-label="Actions" />
</Tr>
</Thead>
<Tbody>
{rowPages[page - 1].map((row, index) => (
<Tr key={row.id}>
<Td>{row.displayName}</Td>
<Td>{row.id}</Td>
<Td>
<Radio
className="workspace-kind-form-radio"
id={`default-${ariaLabel}-${index}`}
name={`default-${ariaLabel}-${index}-radio`}
isChecked={defaultId === row.id}
onChange={() => {
console.log(row.id);
setDefaultId(row.id);
}}
aria-label={`Select ${row.id} as default`}
/>
</Td>
<Td>
{row.labels.length > 0 &&
row.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(perPage * (page - 1) + index)}>
Edit
</DropdownItem>
<DropdownItem onClick={() => openDeleteModal(perPage * (page - 1) + index)}>
Remove
</DropdownItem>
</Dropdown>
</Td>
</Tr>
))}
</Tbody>
</Table>
<Pagination
itemCount={rows.length}
widgetId="pagination-bottom"
perPage={perPage}
page={page}
variant={PaginationVariant.bottom}
isCompact
onSetPage={onSetPage}
onPerPageSelect={onPerPageSelect}
/>
</PageSection>
);
};

View File

@ -104,6 +104,45 @@ export const emptyPodConfig: WorkspacePodConfigValue = {
to: '', to: '',
}, },
}; };
export const EMPTY_WORKSPACE_KIND_FORM_DATA = {
properties: {
displayName: '',
description: '',
deprecated: false,
deprecationMessage: '',
hidden: false,
icon: { url: '' },
logo: { url: '' },
},
imageConfig: {
default: '',
values: [],
},
podConfig: {
default: '',
values: [],
},
podTemplate: {
podMetadata: {
labels: {},
annotations: {},
},
volumeMounts: {
home: '',
},
extraVolumeMounts: [],
culling: {
enabled: false,
maxInactiveSeconds: 86400,
activityProbe: {
jupyter: {
lastActivity: true,
},
},
},
},
};
// 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) // 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[] => { export const getResources = (currConfig: WorkspaceKindPodConfigValue): PodResourceEntry[] => {
const grouped = new Map<string, { request: string; limit: string }>([ const grouped = new Map<string, { request: string; limit: string }>([

View File

@ -2,9 +2,6 @@ import React, { useCallback, useState } from 'react';
import { import {
Button, Button,
Content, Content,
Dropdown,
MenuToggle,
DropdownItem,
Modal, Modal,
ModalHeader, ModalHeader,
ModalFooter, ModalFooter,
@ -13,14 +10,13 @@ import {
EmptyStateFooter, EmptyStateFooter,
EmptyStateActions, EmptyStateActions,
EmptyStateBody, EmptyStateBody,
Label,
getUniqueId,
ExpandableSection, ExpandableSection,
} from '@patternfly/react-core'; } from '@patternfly/react-core';
import { Table, Thead, Tbody, Tr, Th, Td } from '@patternfly/react-table'; import { PlusCircleIcon, CubesIcon } from '@patternfly/react-icons';
import { PlusCircleIcon, EllipsisVIcon, CubesIcon } from '@patternfly/react-icons';
import { WorkspaceKindImageConfigData, WorkspaceKindImageConfigValue } from '~/app/types'; import { WorkspaceKindImageConfigData, WorkspaceKindImageConfigValue } from '~/app/types';
import { emptyImage } from '~/app/pages/WorkspaceKinds/Form/helpers'; import { emptyImage } from '~/app/pages/WorkspaceKinds/Form/helpers';
import { WorkspaceKindFormPaginatedTable } from '~/app/pages/WorkspaceKinds/Form/WorkspaceKindFormPaginatedTable';
import { WorkspaceKindFormImageModal } from './WorkspaceKindFormImageModal'; import { WorkspaceKindFormImageModal } from './WorkspaceKindFormImageModal';
interface WorkspaceKindFormImageProps { interface WorkspaceKindFormImageProps {
@ -38,7 +34,6 @@ export const WorkspaceKindFormImage: React.FC<WorkspaceKindFormImageProps> = ({
const [defaultId, setDefaultId] = useState(imageConfig.default || ''); const [defaultId, setDefaultId] = useState(imageConfig.default || '');
const [isModalOpen, setIsModalOpen] = useState(false); const [isModalOpen, setIsModalOpen] = useState(false);
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
const [dropdownOpen, setDropdownOpen] = useState<number | null>(null);
const [editIndex, setEditIndex] = useState<number | null>(null); const [editIndex, setEditIndex] = useState<number | null>(null);
const [deleteIndex, setDeleteIndex] = useState<number | null>(null); const [deleteIndex, setDeleteIndex] = useState<number | null>(null);
const [image, setImage] = useState<WorkspaceKindImageConfigValue>({ ...emptyImage }); const [image, setImage] = useState<WorkspaceKindImageConfigValue>({ ...emptyImage });
@ -125,70 +120,17 @@ export const WorkspaceKindFormImage: React.FC<WorkspaceKindFormImageProps> = ({
)} )}
{imageConfig.values.length > 0 && ( {imageConfig.values.length > 0 && (
<div> <div>
<Table aria-label="Images table"> <WorkspaceKindFormPaginatedTable
<Thead> ariaLabel="Images table"
<Tr> rows={imageConfig.values}
<Th>Display Name</Th> defaultId={defaultId}
<Th>ID</Th> setDefaultId={(id) => {
<Th screenReaderText="Row select">Default</Th> updateImageConfig({ ...imageConfig, default: id });
<Th>Hidden</Th> setDefaultId(id);
<Th>Labels</Th> }}
<Th aria-label="Actions" /> handleEdit={handleEdit}
</Tr> openDeleteModal={openDeleteModal}
</Thead> />
<Tbody>
{imageConfig.values.map((img, index) => (
<Tr key={img.id}>
<Td>{img.displayName}</Td>
<Td>{img.id}</Td>
<Td>
<input
type="radio"
name="default-image"
checked={defaultId === img.id}
onChange={() => {
setDefaultId(img.id);
updateImageConfig({ ...imageConfig, default: img.id });
}}
aria-label={`Select ${img.id} as default`}
/>
</Td>
<Td>{img.hidden ? 'Yes' : 'No'}</Td>
<Td>
{img.labels.length > 0 &&
img.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>
{addImageBtn} {addImageBtn}
</div> </div>
)} )}

View File

@ -138,7 +138,7 @@ export const WorkspaceKindFormImageModal: React.FC<WorkspaceKindFormImageModalPr
</FormSelect> </FormSelect>
</FormGroup> </FormGroup>
<WorkspaceKindFormImagePort <WorkspaceKindFormImagePort
ports={image.ports} ports={image.ports || []}
setPorts={(ports) => setImage({ ...image, ports })} setPorts={(ports) => setImage({ ...image, ports })}
/> />
{mode === 'edit' && ( {mode === 'edit' && (

View File

@ -1,4 +1,4 @@
import React, { useState, useEffect, useCallback, useMemo } from 'react'; import React, { useState, useEffect, useCallback, useMemo, useRef } from 'react';
import { import {
FormSelect, FormSelect,
FormSelectOption, FormSelectOption,
@ -6,13 +6,18 @@ import {
Split, Split,
SplitItem, SplitItem,
} from '@patternfly/react-core'; } from '@patternfly/react-core';
import { CPU_UNITS, MEMORY_UNITS_FOR_SELECTION, UnitOption } from '~/shared/utilities/valueUnits'; import {
CPU_UNITS,
MEMORY_UNITS_FOR_SELECTION,
TIME_UNIT_FOR_SELECTION,
UnitOption,
} from '~/shared/utilities/valueUnits';
import { parseResourceValue } from '~/shared/utilities/WorkspaceUtils'; import { parseResourceValue } from '~/shared/utilities/WorkspaceUtils';
interface ResourceInputWrapperProps { interface ResourceInputWrapperProps {
value: string; value: string;
onChange: (value: string) => void; onChange: (value: string) => void;
type: 'cpu' | 'memory' | 'custom'; type: 'cpu' | 'memory' | 'time' | 'custom';
min?: number; min?: number;
max?: number; max?: number;
step?: number; step?: number;
@ -26,6 +31,7 @@ const unitMap: {
} = { } = {
memory: MEMORY_UNITS_FOR_SELECTION, memory: MEMORY_UNITS_FOR_SELECTION,
cpu: CPU_UNITS, cpu: CPU_UNITS,
time: TIME_UNIT_FOR_SELECTION,
}; };
const DEFAULT_STEP = 1; const DEFAULT_STEP = 1;
@ -34,7 +40,6 @@ const DEFAULT_UNITS = {
memory: 'Mi', memory: 'Mi',
cpu: '', cpu: '',
}; };
export const ResourceInputWrapper: React.FC<ResourceInputWrapperProps> = ({ export const ResourceInputWrapper: React.FC<ResourceInputWrapperProps> = ({
value, value,
onChange, onChange,
@ -48,22 +53,47 @@ export const ResourceInputWrapper: React.FC<ResourceInputWrapperProps> = ({
}) => { }) => {
const [inputValue, setInputValue] = useState(value); const [inputValue, setInputValue] = useState(value);
const [unit, setUnit] = useState<string>(''); const [unit, setUnit] = useState<string>('');
const isTimeInitialized = useRef(false);
useEffect(() => { useEffect(() => {
if (type === 'custom') { if (type === 'time') {
setInputValue(value); // Initialize time only once
return; if (!isTimeInitialized.current) {
const seconds = parseFloat(value) || 0;
let defaultUnit = 60; // Default to minutes
if (seconds >= 86400) {
defaultUnit = 86400; // Days
} else if (seconds >= 3600) {
defaultUnit = 3600; // Hours
} else if (seconds >= 60) {
defaultUnit = 60; // Minutes
} else {
defaultUnit = 1; // Seconds
}
setUnit(defaultUnit.toString());
setInputValue((seconds / defaultUnit).toString());
isTimeInitialized.current = true;
}
} else {
if (type === 'custom') {
setInputValue(value);
return;
}
const [numericValue, extractedUnit] = parseResourceValue(value, type);
setInputValue(String(numericValue || ''));
setUnit(extractedUnit?.unit || DEFAULT_UNITS[type]);
} }
const [numericValue, extractedUnit] = parseResourceValue(value, type); }, [type, value]);
setInputValue(String(numericValue || ''));
setUnit(extractedUnit?.unit || DEFAULT_UNITS[type]);
}, [value, type]);
const handleInputChange = useCallback( const handleInputChange = useCallback(
(newValue: string) => { (newValue: string) => {
setInputValue(newValue); setInputValue(newValue);
if (type === 'custom') { if (type === 'custom') {
onChange(newValue); onChange(newValue);
} else if (type === 'time') {
const numericValue = parseFloat(newValue) || 0;
const unitMultiplier = parseFloat(unit) || 1;
onChange(String(numericValue * unitMultiplier));
} else { } else {
onChange(newValue ? `${newValue}${unit}` : ''); onChange(newValue ? `${newValue}${unit}` : '');
} }
@ -73,12 +103,24 @@ export const ResourceInputWrapper: React.FC<ResourceInputWrapperProps> = ({
const handleUnitChange = useCallback( const handleUnitChange = useCallback(
(newUnit: string) => { (newUnit: string) => {
setUnit(newUnit); if (type === 'time') {
if (inputValue) { const currentValue = parseFloat(inputValue) || 0;
onChange(`${inputValue}${newUnit}`); const oldUnitMultiplier = parseFloat(unit) || 1;
const newUnitMultiplier = parseFloat(newUnit) || 1;
// Convert the current value to the new unit
const valueInSeconds = currentValue * oldUnitMultiplier;
const valueInNewUnit = valueInSeconds / newUnitMultiplier;
setUnit(newUnit);
setInputValue(valueInNewUnit.toString());
onChange(String(valueInSeconds));
} else {
setUnit(newUnit);
if (inputValue) {
onChange(`${inputValue}${newUnit}`);
}
} }
}, },
[inputValue, onChange], [inputValue, onChange, type, unit],
); );
const handleIncrement = useCallback(() => { const handleIncrement = useCallback(() => {
@ -104,7 +146,13 @@ export const ResourceInputWrapper: React.FC<ResourceInputWrapperProps> = ({
const unitOptions = useMemo( const unitOptions = useMemo(
() => () =>
type !== 'custom' type !== 'custom'
? unitMap[type].map((u) => <FormSelectOption label={u.name} key={u.name} value={u.unit} />) ? unitMap[type].map((u) => (
<FormSelectOption
label={u.name}
key={u.name}
value={type === 'time' ? u.weight : u.unit}
/>
))
: [], : [],
[type], [type],
); );
@ -136,6 +184,7 @@ export const ResourceInputWrapper: React.FC<ResourceInputWrapperProps> = ({
onChange={(_, v) => handleUnitChange(v)} onChange={(_, v) => handleUnitChange(v)}
id={`${ariaLabel}-unit-select`} id={`${ariaLabel}-unit-select`}
isDisabled={isDisabled} isDisabled={isDisabled}
className="workspace-kind-unit-select"
> >
{unitOptions} {unitOptions}
</FormSelect> </FormSelect>

View File

@ -2,9 +2,6 @@ import React, { useCallback, useState } from 'react';
import { import {
Button, Button,
Content, Content,
Dropdown,
MenuToggle,
DropdownItem,
Modal, Modal,
ModalHeader, ModalHeader,
ModalFooter, ModalFooter,
@ -14,14 +11,11 @@ import {
EmptyStateActions, EmptyStateActions,
ExpandableSection, ExpandableSection,
EmptyStateBody, EmptyStateBody,
Label,
getUniqueId,
} from '@patternfly/react-core'; } from '@patternfly/react-core';
import { Table, Thead, Tbody, Tr, Th, Td } from '@patternfly/react-table'; import { PlusCircleIcon, CubesIcon } from '@patternfly/react-icons';
import { PlusCircleIcon, EllipsisVIcon, CubesIcon } from '@patternfly/react-icons';
import { emptyPodConfig } from '~/app/pages/WorkspaceKinds/Form/helpers'; import { emptyPodConfig } from '~/app/pages/WorkspaceKinds/Form/helpers';
import { WorkspaceKindPodConfigValue, WorkspaceKindPodConfigData } from '~/app/types'; import { WorkspaceKindPodConfigValue, WorkspaceKindPodConfigData } from '~/app/types';
import { WorkspaceKindFormPaginatedTable } from '~/app/pages/WorkspaceKinds/Form/WorkspaceKindFormPaginatedTable';
import { WorkspaceKindFormPodConfigModal } from './WorkspaceKindFormPodConfigModal'; import { WorkspaceKindFormPodConfigModal } from './WorkspaceKindFormPodConfigModal';
interface WorkspaceKindFormPodConfigProps { interface WorkspaceKindFormPodConfigProps {
@ -37,7 +31,6 @@ export const WorkspaceKindFormPodConfig: React.FC<WorkspaceKindFormPodConfigProp
const [defaultId, setDefaultId] = useState(podConfig.default || ''); const [defaultId, setDefaultId] = useState(podConfig.default || '');
const [isModalOpen, setIsModalOpen] = useState(false); const [isModalOpen, setIsModalOpen] = useState(false);
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
const [dropdownOpen, setDropdownOpen] = useState<number | null>(null);
const [editIndex, setEditIndex] = useState<number | null>(null); const [editIndex, setEditIndex] = useState<number | null>(null);
const [deleteIndex, setDeleteIndex] = useState<number | null>(null); const [deleteIndex, setDeleteIndex] = useState<number | null>(null);
const [currConfig, setCurrConfig] = useState<WorkspaceKindPodConfigValue>({ ...emptyPodConfig }); const [currConfig, setCurrConfig] = useState<WorkspaceKindPodConfigValue>({ ...emptyPodConfig });
@ -128,69 +121,17 @@ export const WorkspaceKindFormPodConfig: React.FC<WorkspaceKindFormPodConfigProp
)} )}
{podConfig.values.length > 0 && ( {podConfig.values.length > 0 && (
<> <>
<Table aria-label="pod configs table"> <WorkspaceKindFormPaginatedTable
<Thead> ariaLabel="Pod Configs Table"
<Tr> rows={podConfig.values}
<Th>Display Name</Th> defaultId={defaultId}
<Th>ID</Th> setDefaultId={(id) => {
<Th screenReaderText="Row select">Default</Th> updatePodConfig({ ...podConfig, default: id });
<Th>Hidden</Th> setDefaultId(id);
<Th>Labels</Th> }}
<Th aria-label="Actions" /> handleEdit={handleEdit}
</Tr> openDeleteModal={openDeleteModal}
</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} {addConfigBtn}
</> </>
)} )}

View File

@ -312,6 +312,7 @@ export const WorkspaceKindFormResource: React.FC<WorkspaceKindFormResourceProps>
onChange={(_event, value) => handleChange(res.id, 'type', value)} onChange={(_event, value) => handleChange(res.id, 'type', value)}
/> />
</GridItem> </GridItem>
<GridItem span={2}> <GridItem span={2}>
<Button <Button
variant="link" variant="link"

View File

@ -0,0 +1,197 @@
import {
ExpandableSection,
Form,
FormFieldGroup,
FormFieldGroupHeader,
FormGroup,
HelperText,
HelperTextItem,
Switch,
} from '@patternfly/react-core';
import React, { useCallback, useState } from 'react';
import { WorkspaceKindPodTemplateData } from '~/app/types';
import { EditableLabels } from '~/app/pages/WorkspaceKinds/Form/EditableLabels';
import { WorkspacePodVolumeMount } from '~/shared/api/backendApiTypes';
import { ResourceInputWrapper } from '~/app/pages/WorkspaceKinds/Form/podConfig/ResourceInputWrapper';
import { WorkspaceFormPropertiesVolumes } from '~/app/pages/Workspaces/Form/properties/WorkspaceFormPropertiesVolumes';
interface WorkspaceKindFormPodTemplateProps {
podTemplate: WorkspaceKindPodTemplateData;
updatePodTemplate: (template: WorkspaceKindPodTemplateData) => void;
}
export const WorkspaceKindFormPodTemplate: React.FC<WorkspaceKindFormPodTemplateProps> = ({
podTemplate,
updatePodTemplate,
}) => {
const [isExpanded, setIsExpanded] = useState(false);
const [volumes, setVolumes] = useState<WorkspacePodVolumeMount[]>([]);
const toggleCullingEnabled = useCallback(
(checked: boolean) => {
if (podTemplate.culling) {
updatePodTemplate({
...podTemplate,
culling: {
...podTemplate.culling,
enabled: checked,
},
});
}
},
[podTemplate, updatePodTemplate],
);
const handleVolumes = useCallback(
(newVolumes: WorkspacePodVolumeMount[]) => {
setVolumes(newVolumes);
updatePodTemplate({
...podTemplate,
extraVolumeMounts: volumes,
});
},
[podTemplate, updatePodTemplate, volumes],
);
return (
<div className="pf-u-mb-0">
<ExpandableSection
toggleText="Pod Lifecycle & Customization"
onToggle={() => setIsExpanded((prev) => !prev)}
isExpanded={isExpanded}
isIndented
>
<Form>
<FormFieldGroup
aria-label="Pod Metadata"
header={
<FormFieldGroupHeader
titleText={{
text: 'Pod Metadata',
id: 'workspace-kind-pod-metadata',
}}
titleDescription={
<HelperText>
Edit mutable metadata of all pods created with this Workspace Kind.
</HelperText>
}
/>
}
>
<EditableLabels
rows={Object.entries(podTemplate.podMetadata.labels).map((entry) => ({
key: entry[0],
value: entry[1],
}))}
setRows={(newLabels) => {
updatePodTemplate({
...podTemplate,
podMetadata: {
...podTemplate.podMetadata,
labels: newLabels.reduce((acc: { [k: string]: string }, { key, value }) => {
acc[key] = value;
return acc;
}, {}),
},
});
}}
/>
<EditableLabels
title="Annotations"
description="Use annotations to attach arbitrary non-identifying metadata to Kubernetes objects."
buttonLabel="Annotation"
rows={Object.entries(podTemplate.podMetadata.annotations).map((entry) => ({
key: entry[0],
value: entry[1],
}))}
setRows={(newAnnotations) => {
updatePodTemplate({
...podTemplate,
podMetadata: {
...podTemplate.podMetadata,
annotations: newAnnotations.reduce(
(acc: { [k: string]: string }, { key, value }) => {
acc[key] = value;
return acc;
},
{},
),
},
});
}}
/>
</FormFieldGroup>
{/* podTemplate.culling is currently not developed in the backend */}
{podTemplate.culling && (
<FormFieldGroup
aria-label="Pod Culling"
header={
<FormFieldGroupHeader
titleText={{
text: 'Pod Culling',
id: 'workspace-kind-pod-culling',
}}
titleDescription={
<HelperText>
<HelperTextItem variant="warning">
Warning: Only for JupyterLab deployments
</HelperTextItem>
Culling scales the number of pods in a Workspace to zero based on its last
activity by polling Jupyter&apos;s status endpoint.
</HelperText>
}
/>
}
>
<FormGroup>
<Switch
isChecked={podTemplate.culling.enabled || false}
label="Enabled"
aria-label="pod template enable culling controlled check"
onChange={(_, checked) => toggleCullingEnabled(checked)}
id="workspace-kind-pod-template-culling-enabled"
name="culling-enabled"
/>
</FormGroup>
<FormGroup label="Max Inactive Period">
<ResourceInputWrapper
value={String(podTemplate.culling.maxInactiveSeconds || 86400)}
type="time"
onChange={(value) =>
podTemplate.culling &&
updatePodTemplate({
...podTemplate,
culling: {
...podTemplate.culling,
maxInactiveSeconds: Number(value),
},
})
}
step={1}
aria-label="max inactive period input"
isDisabled={!podTemplate.culling.enabled}
/>
</FormGroup>
</FormFieldGroup>
)}
<FormFieldGroup
aria-label="Additional Volumes"
header={
<FormFieldGroupHeader
titleText={{
text: 'Additional Volumes',
id: 'workspace-kind-extra-volume',
}}
titleDescription={
<HelperText>Configure the paths to mount additional PVCs.</HelperText>
}
/>
}
>
<WorkspaceFormPropertiesVolumes volumes={volumes} setVolumes={handleVolumes} />
</FormFieldGroup>
</Form>
</ExpandableSection>
</div>
);
};

View File

@ -430,8 +430,17 @@ export const WorkspaceKinds: React.FunctionComponent = () => {
title: 'View Details', title: 'View Details',
onClick: () => viewDetailsClick(workspaceKind), onClick: () => viewDetailsClick(workspaceKind),
}, },
{
id: 'edit-workspace-kind',
title: 'Edit',
onClick: () =>
navigate('workspaceKindEdit', {
params: { kind: workspaceKind.name },
state: { workspaceKindName: workspaceKind.name },
}),
},
], ],
[viewDetailsClick], [navigate, viewDetailsClick],
); );
const workspaceDetailsContent = ( const workspaceDetailsContent = (

View File

@ -1,5 +1,5 @@
import React, { useCallback, useState } from 'react'; import React, { useCallback, useState } from 'react';
import { EllipsisVIcon } from '@patternfly/react-icons'; import { EllipsisVIcon, PlusCircleIcon } from '@patternfly/react-icons';
import { Table, Thead, Tbody, Tr, Th, Td, TableVariant } from '@patternfly/react-table'; import { Table, Thead, Tbody, Tr, Th, Td, TableVariant } from '@patternfly/react-table';
import { import {
Button, Button,
@ -152,7 +152,12 @@ export const WorkspaceFormPropertiesSecrets: React.FC<WorkspaceFormPropertiesSec
</Tbody> </Tbody>
</Table> </Table>
)} )}
<Button variant="primary" onClick={() => setIsModalOpen(true)} style={{ marginTop: '1rem' }}> <Button
variant="primary"
icon={<PlusCircleIcon />}
onClick={() => setIsModalOpen(true)}
style={{ marginTop: '1rem', width: 'fit-content' }}
>
Create Secret Create Secret
</Button> </Button>
<Modal isOpen={isModalOpen} onClose={clearForm} variant={ModalVariant.small}> <Modal isOpen={isModalOpen} onClose={clearForm} variant={ModalVariant.small}>

View File

@ -14,7 +14,7 @@ import {
Switch, Switch,
TextInput, TextInput,
} from '@patternfly/react-core'; } from '@patternfly/react-core';
import { EllipsisVIcon } from '@patternfly/react-icons'; import { EllipsisVIcon, PlusCircleIcon } from '@patternfly/react-icons';
import { Table, TableVariant, Tbody, Td, Th, Thead, Tr } from '@patternfly/react-table'; import { Table, TableVariant, Tbody, Td, Th, Thead, Tr } from '@patternfly/react-table';
import { WorkspacePodVolumeMount } from '~/shared/api/backendApiTypes'; import { WorkspacePodVolumeMount } from '~/shared/api/backendApiTypes';
@ -126,9 +126,10 @@ export const WorkspaceFormPropertiesVolumes: React.FC<WorkspaceFormPropertiesVol
</Table> </Table>
)} )}
<Button <Button
variant="primary" variant="link"
icon={<PlusCircleIcon />}
onClick={() => setIsModalOpen(true)} onClick={() => setIsModalOpen(true)}
style={{ marginTop: '1rem' }} style={{ marginTop: '1rem', width: 'fit-content' }}
className="pf-u-mt-md" className="pf-u-mt-md"
> >
Create Volume Create Volume

View File

@ -6,6 +6,8 @@ import {
WorkspacePodSecretMount, WorkspacePodSecretMount,
Workspace, Workspace,
WorkspaceImageRef, WorkspaceImageRef,
WorkspacePodVolumeMounts,
WorkspaceKindPodMetadata,
} from '~/shared/api/backendApiTypes'; } from '~/shared/api/backendApiTypes';
export interface WorkspaceColumnDefinition { export interface WorkspaceColumnDefinition {
@ -54,9 +56,9 @@ export interface WorkspaceKindProperties {
} }
export interface WorkspaceKindImageConfigValue extends WorkspaceImageConfigValue { export interface WorkspaceKindImageConfigValue extends WorkspaceImageConfigValue {
imagePullPolicy: ImagePullPolicy.IfNotPresent | ImagePullPolicy.Always | ImagePullPolicy.Never; imagePullPolicy?: ImagePullPolicy.IfNotPresent | ImagePullPolicy.Always | ImagePullPolicy.Never;
ports: WorkspaceKindImagePort[]; ports?: WorkspaceKindImagePort[];
image: string; image?: string;
} }
export enum ImagePullPolicy { export enum ImagePullPolicy {
@ -92,9 +94,26 @@ export interface WorkspaceKindPodConfigData {
default: string; default: string;
values: WorkspaceKindPodConfigValue[]; values: WorkspaceKindPodConfigValue[];
} }
export interface WorkspaceKindPodCulling {
enabled: boolean;
maxInactiveSeconds: number;
activityProbe: {
jupyter: {
lastActivity: boolean;
};
};
}
export interface WorkspaceKindPodTemplateData {
podMetadata: WorkspaceKindPodMetadata;
volumeMounts: WorkspacePodVolumeMounts;
culling?: WorkspaceKindPodCulling;
extraVolumeMounts?: WorkspacePodVolumeMount[];
}
export interface WorkspaceKindFormData { export interface WorkspaceKindFormData {
properties: WorkspaceKindProperties; properties: WorkspaceKindProperties;
imageConfig: WorkspaceKindImageConfigData; imageConfig: WorkspaceKindImageConfigData;
podConfig: WorkspaceKindPodConfigData; podConfig: WorkspaceKindPodConfigData;
podTemplate: WorkspaceKindPodTemplateData;
} }

View File

@ -233,6 +233,10 @@ export const isErrorEnvelope = (e: unknown): e is ErrorEnvelope =>
typeof (e as { error: { message: unknown } }).error.message === 'string'; typeof (e as { error: { message: unknown } }).error.message === 'string';
export function extractNotebookResponse<T>(response: unknown): T { export function extractNotebookResponse<T>(response: unknown): T {
// Check if this is an error envelope first
if (isErrorEnvelope(response)) {
throw new ErrorEnvelopeException(response);
}
if (isNotebookResponse<T>(response)) { if (isNotebookResponse<T>(response)) {
return response.data; return response.data;
} }
@ -260,6 +264,9 @@ export async function wrapRequest<T>(promise: Promise<T>, extractData = true): P
const res = await handleRestFailures<T>(promise); const res = await handleRestFailures<T>(promise);
return extractData ? extractNotebookResponse<T>(res) : res; return extractData ? extractNotebookResponse<T>(res) : res;
} catch (error) { } catch (error) {
if (error instanceof ErrorEnvelopeException) {
throw error;
}
throw new ErrorEnvelopeException(extractErrorEnvelope(error)); throw new ErrorEnvelopeException(extractErrorEnvelope(error));
} }
} }

View File

@ -259,6 +259,28 @@
z-index: 1; z-index: 1;
} }
// Form controls with number inputs - specific styling overrides
.mui-theme .pf-v6-c-form__group:has(.pf-v6-c-number-input) {
.pf-v6-c-form__label {
top: 30%;
font-size: 16px;
left: 0;
}
.pf-v6-c-form-control {
// Override default form control padding to match button padding in this context
--pf-v6-c-form-control--PaddingBlockStart: var(--mui-spacing-8px);
--pf-v6-c-form-control--PaddingBlockEnd: var(--mui-spacing-8px);
&.workspace-kind-unit-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-form-control input::placeholder { .mui-theme .pf-v6-c-form-control input::placeholder {
--pf-v6-c-form-control--m-placeholder--Color: var(--mui-palette-grey-600); --pf-v6-c-form-control--m-placeholder--Color: var(--mui-palette-grey-600);
} }
@ -614,7 +636,7 @@
--pf-v6-c-progress-stepper__step-icon--Color: var(--mui-palette-success-main); --pf-v6-c-progress-stepper__step-icon--Color: var(--mui-palette-success-main);
} }
.mui-theme .pf-v6-c-radio.pf-m-standalone .pf-v6-c-radio__input { .mui-theme .pf-v6-c-radio.pf-m-standalone:not(.workspace-kind-form-radio) .pf-v6-c-radio__input {
display: none; display: none;
} }

View File

@ -40,6 +40,13 @@ 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 TIME_UNIT_FOR_SELECTION: UnitOption[] = [
{ name: 'Minutes', unit: 'Minutes', weight: 60 },
{ name: 'Hours', unit: 'Hours', weight: 60 * 60 },
{ name: 'Days', unit: 'Days', weight: 60 * 60 * 24 },
];
export const OTHER: UnitOption[] = [{ name: '', unit: '', weight: 1 }]; export const OTHER: UnitOption[] = [{ name: '', unit: '', weight: 1 }];
export const splitValueUnit = ( export const splitValueUnit = (