mirror of https://github.com/portainer/portainer
refactor(app): persisted folders form section [EE-6235] (#10693)
* refactor(app): persisted folder section [EE-6235]pull/10695/head
parent
7a2412b1be
commit
e07ee05ee7
@ -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 };
|
||||
}
|
@ -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 '';
|
||||
}
|
@ -0,0 +1 @@
|
||||
export { PersistedFoldersFormSection } from './PersistedFoldersFormSection';
|
@ -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(),
|
||||
}),
|
||||
});
|
||||
}
|
@ -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;
|
||||
};
|
||||
};
|
@ -1,21 +0,0 @@
|
||||
import { useQuery } from 'react-query';
|
||||
|
||||
import { withError } from '@/react-tools/react-query';
|
||||
import { EnvironmentId } from '@/react/portainer/environments/types';
|
||||
|
||||
import { getPVCsForCluster } from './service';
|
||||
|
||||
// useQuery to get a list of all persistent volume claims from an array of namespaces
|
||||
export function usePVCsForCluster(
|
||||
environemtId: EnvironmentId,
|
||||
namespaces?: string[]
|
||||
) {
|
||||
return useQuery(
|
||||
['environments', environemtId, 'kubernetes', 'pvcs'],
|
||||
() => namespaces && getPVCsForCluster(environemtId, namespaces),
|
||||
{
|
||||
...withError('Unable to retrieve perrsistent volume claims'),
|
||||
enabled: !!namespaces,
|
||||
}
|
||||
);
|
||||
}
|
@ -1,18 +1,33 @@
|
||||
import { useQuery } from 'react-query';
|
||||
import { PersistentVolumeClaimList } from 'kubernetes-types/core/v1';
|
||||
|
||||
import axios from '@/portainer/services/axios';
|
||||
import { EnvironmentId } from '@/react/portainer/environments/types';
|
||||
import { withError } from '@/react-tools/react-query';
|
||||
import axios from '@/portainer/services/axios';
|
||||
|
||||
import { parseKubernetesAxiosError } from '../axiosError';
|
||||
|
||||
export async function getPVCsForCluster(
|
||||
// useQuery to get a list of all persistent volume claims from an array of namespaces
|
||||
export function usePVCsQuery(
|
||||
environmentId: EnvironmentId,
|
||||
namespaces: string[]
|
||||
namespaces?: string[]
|
||||
) {
|
||||
return useQuery(
|
||||
['environments', environmentId, 'kubernetes', 'pvcs'],
|
||||
async () => {
|
||||
if (!namespaces) {
|
||||
return [];
|
||||
}
|
||||
const pvcs = await Promise.all(
|
||||
namespaces.map((namespace) => getPVCs(environmentId, namespace))
|
||||
namespaces?.map((namespace) => getPVCs(environmentId, namespace))
|
||||
);
|
||||
return pvcs.flat();
|
||||
},
|
||||
{
|
||||
...withError('Unable to retrieve perrsistent volume claims'),
|
||||
enabled: !!namespaces,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
// get all persistent volume claims for a namespace
|
Loading…
Reference in new issue