1
0
Fork 0
mirror of https://github.com/portainer/portainer.git synced 2025-07-24 07:49:41 +02:00

refactor(app): persisted folders form section [EE-6235] (#10693)

* refactor(app): persisted folder section [EE-6235]
This commit is contained in:
Ali 2024-01-03 09:46:26 +13:00 committed by GitHub
parent 7a2412b1be
commit e07ee05ee7
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
39 changed files with 732 additions and 374 deletions

View file

@ -0,0 +1,240 @@
import { KubernetesApplicationTypes } from 'Kubernetes/models/application/models';
import clsx from 'clsx';
import { StorageClass } from '@/react/portainer/environments/types';
import { ItemError } from '@@/form-components/InputList/InputList';
import { Option } from '@@/form-components/PortainerSelect';
import { InputGroup } from '@@/form-components/InputGroup';
import { Select } from '@@/form-components/ReactSelect';
import { Input } from '@@/form-components/Input';
import { isErrorType } from '@@/form-components/formikUtils';
import { FormError } from '@@/form-components/FormError';
import { ButtonSelector } from '@@/form-components/ButtonSelector/ButtonSelector';
import { ApplicationFormValues } from '../../types';
import { ExistingVolume, PersistedFolderFormValue } from './types';
type Props = {
initialValues: PersistedFolderFormValue[];
item: PersistedFolderFormValue;
onChange: (value: PersistedFolderFormValue) => void;
error: ItemError<PersistedFolderFormValue>;
storageClasses: StorageClass[];
index: number;
PVCOptions: Option<string>[];
availableVolumes: ExistingVolume[];
isEdit: boolean;
applicationValues: ApplicationFormValues;
};
export function PersistedFolderItem({
initialValues,
item,
onChange,
error,
storageClasses,
index,
PVCOptions,
availableVolumes,
isEdit,
applicationValues,
}: Props) {
// rule out the error being of type string
const formikError = isErrorType(error) ? error : undefined;
return (
<div className="flex items-start flex-wrap gap-x-2 gap-y-2">
<div>
<InputGroup
size="small"
className={clsx('min-w-[250px]', item.needsDeletion && 'striked')}
>
<InputGroup.Addon required>Path in container</InputGroup.Addon>
<Input
type="text"
placeholder="e.g. /data"
disabled={
(isEdit && isExistingPersistedFolder()) ||
applicationValues.Containers.length > 1
}
value={item.containerPath}
onChange={(e) =>
onChange({
...item,
containerPath: e.target.value,
})
}
data-cy={`k8sAppCreate-containerPathInput_${index}`}
/>
</InputGroup>
{formikError?.containerPath && (
<FormError>{formikError?.containerPath}</FormError>
)}
</div>
{isToggleVolumeTypeVisible() && (
<ButtonSelector<boolean>
onChange={(isNewVolume) =>
onChange({
...item,
useNewVolume: isNewVolume,
size: isNewVolume ? item.size : '',
existingVolume: isNewVolume ? undefined : availableVolumes[0],
})
}
value={item.useNewVolume}
options={[
{ value: true, label: 'New volume' },
{
value: false,
label: 'Existing volume',
disabled: PVCOptions.length === 0,
},
]}
/>
)}
{item.useNewVolume && (
<>
<div>
<InputGroup
size="small"
className={clsx(
'min-w-fit flex',
item.needsDeletion && 'striked'
)}
>
<InputGroup.Addon className="min-w-fit" required>
Requested size
</InputGroup.Addon>
<Input
className="!rounded-none -mr-[1px] !w-20"
type="number"
placeholder="e.g. 20"
min="0"
disabled={
(isEdit && isExistingPersistedFolder()) ||
applicationValues.Containers.length > 1
}
value={item.size}
onChange={(e) =>
onChange({
...item,
size: e.target.value,
})
}
data-cy={`k8sAppCreate-persistentFolderSizeInput_${index}`}
/>
<Select<Option<string>>
size="sm"
className="min-w-fit"
options={[
{ label: 'MB', value: 'MB' },
{ label: 'GB', value: 'GB' },
{ label: 'TB', value: 'TB' },
]}
value={{
label: item.sizeUnit ?? '',
value: item.sizeUnit ?? '',
}}
onChange={(option) =>
onChange({ ...item, sizeUnit: option?.value ?? 'GB' })
}
isDisabled={
(isEdit && isExistingPersistedFolder()) ||
applicationValues.Containers.length > 1
}
data-cy={`k8sAppCreate-persistentFolderSizeUnitSelect_${index}`}
/>
</InputGroup>
{formikError?.size && <FormError>{formikError?.size}</FormError>}
</div>
<InputGroup
size="small"
className={clsx(item.needsDeletion && 'striked')}
>
<InputGroup.Addon>Storage</InputGroup.Addon>
<Select<Option<string>>
className="w-40"
size="sm"
options={storageClasses.map((sc) => ({
label: sc.Name,
value: sc.Name,
}))}
value={getStorageClassValue(storageClasses, item)}
onChange={(option) =>
onChange({
...item,
storageClass:
storageClasses.find((sc) => sc.Name === option?.value) ??
storageClasses[0],
})
}
isDisabled={
(isEdit && isExistingPersistedFolder()) ||
applicationValues.Containers.length > 1 ||
storageClasses.length <= 1
}
data-cy={`k8sAppCreate-storageSelect_${index}`}
/>
</InputGroup>
</>
)}
{!item.useNewVolume && (
<InputGroup
size="small"
className={clsx(item.needsDeletion && 'striked')}
>
<InputGroup.Addon>Volume</InputGroup.Addon>
<Select<Option<string>>
className="w-[440px]"
size="sm"
options={PVCOptions}
value={PVCOptions.find(
(pvc) => pvc.value === item.persistentVolumeClaimName
)}
onChange={(option) =>
onChange({
...item,
persistentVolumeClaimName: option?.value,
existingVolume: availableVolumes.find(
(pvc) => pvc.PersistentVolumeClaim.Name === option?.value
),
})
}
isDisabled={
(isEdit && isExistingPersistedFolder()) ||
applicationValues.Containers.length > 1 ||
availableVolumes.length <= 1
}
data-cy={`k8sAppCreate-pvcSelect_${index}`}
/>
</InputGroup>
)}
</div>
);
function isExistingPersistedFolder() {
return !!initialValues?.[index]?.persistentVolumeClaimName;
}
function isToggleVolumeTypeVisible() {
return (
!(isEdit && isExistingPersistedFolder()) && // if it's not an edit of an existing persisted folder
applicationValues.ApplicationType !==
KubernetesApplicationTypes.STATEFULSET && // and if it's not a statefulset
applicationValues.Containers.length <= 1 // and if there is only one container);
);
}
}
function getStorageClassValue(
storageClasses: StorageClass[],
persistedFolder: PersistedFolderFormValue
) {
const matchingClass =
storageClasses.find(
(sc) => sc.Name === persistedFolder.storageClass?.Name
) ?? storageClasses[0];
return { label: matchingClass?.Name, value: matchingClass?.Name };
}

View file

@ -0,0 +1,127 @@
import { FormikErrors } from 'formik';
import { useMemo } from 'react';
import { useCurrentEnvironment } from '@/react/hooks/useCurrentEnvironment';
import { StorageClass } from '@/react/portainer/environments/types';
import { KubernetesApplicationTypes } from '@/kubernetes/models/application/models';
import { Option } from '@@/form-components/PortainerSelect';
import { InlineLoader } from '@@/InlineLoader';
import { FormSection } from '@@/form-components/FormSection';
import { InputList } from '@@/form-components/InputList';
import { TextTip } from '@@/Tip/TextTip';
import { ApplicationFormValues } from '../../types';
import { ExistingVolume, PersistedFolderFormValue } from './types';
import { PersistedFolderItem } from './PersistedFolderItem';
type Props = {
values: PersistedFolderFormValue[];
initialValues: PersistedFolderFormValue[];
onChange: (values: PersistedFolderFormValue[]) => void;
errors: FormikErrors<PersistedFolderFormValue[]>;
isAddPersistentFolderButtonShown: unknown;
isEdit: boolean;
applicationValues: ApplicationFormValues;
availableVolumes: ExistingVolume[];
};
export function PersistedFoldersFormSection({
values,
initialValues,
onChange,
errors,
isAddPersistentFolderButtonShown,
isEdit,
applicationValues,
availableVolumes,
}: Props) {
const environmentQuery = useCurrentEnvironment();
const storageClasses =
environmentQuery.data?.Kubernetes.Configuration.StorageClasses ?? [];
const PVCOptions = usePVCOptions(availableVolumes);
return (
<FormSection
title="Persisted folders"
titleSize="sm"
titleClassName="control-label !text-[0.9em]"
>
{storageClasses.length === 0 && (
<TextTip color="blue">
No storage option is available to persist data, contact your
administrator to enable a storage option.
</TextTip>
)}
{environmentQuery.isLoading && (
<InlineLoader>Loading volumes...</InlineLoader>
)}
<InputList<PersistedFolderFormValue>
value={values}
onChange={onChange}
errors={errors}
isDeleteButtonHidden={isDeleteButtonHidden()}
deleteButtonDataCy="k8sAppCreate-persistentFolderRemoveButton"
addButtonDataCy="k8sAppCreate-persistentFolderAddButton"
disabled={storageClasses.length === 0}
addButtonError={getAddButtonError(storageClasses)}
isAddButtonHidden={!isAddPersistentFolderButtonShown}
renderItem={(item, onChange, index, error) => (
<PersistedFolderItem
item={item}
onChange={onChange}
error={error}
PVCOptions={PVCOptions}
availableVolumes={availableVolumes}
storageClasses={storageClasses ?? []}
index={index}
isEdit={isEdit}
applicationValues={applicationValues}
initialValues={initialValues}
/>
)}
itemBuilder={() => ({
persistentVolumeClaimName:
availableVolumes[0]?.PersistentVolumeClaim.Name || '',
containerPath: '',
size: '',
sizeUnit: 'GB',
storageClass: storageClasses[0],
useNewVolume: true,
existingVolume: undefined,
needsDeletion: false,
})}
addLabel="Add persisted folder"
canUndoDelete={isEdit}
/>
</FormSection>
);
function isDeleteButtonHidden() {
return (
(isEdit &&
applicationValues.ApplicationType ===
KubernetesApplicationTypes.STATEFULSET) ||
applicationValues.Containers.length >= 1
);
}
}
function usePVCOptions(existingPVCs: ExistingVolume[]): Option<string>[] {
return useMemo(
() =>
existingPVCs.map((pvc) => ({
label: pvc.PersistentVolumeClaim.Name ?? '',
value: pvc.PersistentVolumeClaim.Name ?? '',
})),
[existingPVCs]
);
}
function getAddButtonError(storageClasses: StorageClass[]) {
if (storageClasses.length === 0) {
return 'No storage option available';
}
return '';
}

View file

@ -0,0 +1 @@
export { PersistedFoldersFormSection } from './PersistedFoldersFormSection';

View file

@ -0,0 +1,113 @@
import { SchemaOf, array, boolean, object, string } from 'yup';
import filesizeParser from 'filesize-parser';
import _ from 'lodash';
import { StorageClass } from '@/react/portainer/environments/types';
import { buildUniquenessTest } from '@@/form-components/validate-unique';
import { ExistingVolume, PersistedFolderFormValue } from './types';
type FormData = {
namespaceQuotas: unknown;
persistedFolders: PersistedFolderFormValue[];
storageAvailabilities: Record<string, number>;
};
export function persistedFoldersValidation(
formData?: FormData
): SchemaOf<PersistedFolderFormValue[]> {
return array(
object({
persistentVolumeClaimName: string(),
containerPath: string().required('Path is required.'),
size: string().when('useNewVolume', {
is: true,
then: string()
.test(
'quotaExceeded',
'Requested size exceeds available quota for this storage class.',
// eslint-disable-next-line prefer-arrow-callback, func-names
function (this) {
const persistedFolderFormValue = this
.parent as PersistedFolderFormValue;
const quota = formData?.namespaceQuotas;
let quotaExceeded = false;
if (quota) {
const pfs = formData?.persistedFolders;
const groups = _.groupBy(pfs, 'storageClass.Name');
_.forOwn(groups, (storagePfs, storageClassName) => {
if (
storageClassName ===
persistedFolderFormValue.storageClass.Name
) {
const newPfs = _.filter(storagePfs, {
persistentVolumeClaimName: '',
});
const requestedSize = _.reduce(
newPfs,
(sum, pf) =>
pf.useNewVolume && pf.size
? sum +
filesizeParser(`${pf.size}${pf.sizeUnit}`, {
base: 10,
})
: sum,
0
);
if (
formData?.storageAvailabilities[storageClassName] <
requestedSize
) {
quotaExceeded = true;
}
}
});
}
return !quotaExceeded;
}
)
.required('Size is required.'),
}),
sizeUnit: string().when('useNewVolume', {
is: true,
then: string().required('Size unit is required.'),
}),
storageClass: storageClassValidation(),
useNewVolume: boolean().required(),
existingVolume: existingVolumeValidation().nullable(),
needsDeletion: boolean(),
})
).test(
'containerPath',
'This path is already defined.',
buildUniquenessTest(() => 'This path is already defined.', 'containerPath')
);
}
function storageClassValidation(): SchemaOf<StorageClass> {
return object({
Name: string().required(),
AccessModes: array(string().required()).required(),
AllowVolumeExpansion: boolean().required(),
Provisioner: string().required(),
});
}
function existingVolumeValidation(): SchemaOf<ExistingVolume> {
return object({
PersistentVolumeClaim: object({
Id: string().required(),
Name: string().required(),
Namespace: string().required(),
Storage: string().required(),
storageClass: storageClassValidation(),
CreationDate: string().required(),
ApplicationOwner: string().required(),
ApplicationName: string().required(),
PreviousName: string(),
MountPath: string(),
Yaml: string(),
}),
});
}

View file

@ -0,0 +1,28 @@
import { StorageClass } from '@/react/portainer/environments/types';
export type PersistedFolderFormValue = {
containerPath: string;
storageClass: StorageClass;
useNewVolume: boolean;
persistentVolumeClaimName?: string; // empty for new volumes, set for existing volumes
sizeUnit?: string;
size?: string;
existingVolume?: ExistingVolume;
needsDeletion?: boolean;
};
export type ExistingVolume = {
PersistentVolumeClaim: {
Id: string;
Name: string;
Namespace: string;
Storage: string;
storageClass: StorageClass;
CreationDate: string;
ApplicationOwner: string;
ApplicationName: string;
PreviousName?: string;
MountPath?: string;
Yaml?: string;
};
};