feat(ws): Add advanced pod configurations in Workspace Edit (#468)
Signed-off-by: Charles Thao <cthao@redhat.com>
This commit is contained in:
parent
5e9c88f582
commit
0e90e5da65
|
@ -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 }) => {
|
||||||
|
|
|
@ -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>
|
||||||
);
|
);
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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>
|
||||||
|
);
|
||||||
|
};
|
|
@ -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 }>([
|
||||||
|
|
|
@ -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>
|
||||||
)}
|
)}
|
||||||
|
|
|
@ -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' && (
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
|
@ -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"
|
||||||
|
|
|
@ -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'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>
|
||||||
|
);
|
||||||
|
};
|
|
@ -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 = (
|
||||||
|
|
|
@ -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}>
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
|
@ -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));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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 = (
|
||||||
|
|
Loading…
Reference in New Issue