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

* refactor(app): persisted folder section [EE-6235]
pull/10695/head
Ali 11 months ago committed by GitHub
parent 7a2412b1be
commit e07ee05ee7
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23

@ -137,9 +137,9 @@
<table-column-header
col-title="'Storage'"
can-sort="true"
is-sorted="$ctrl.state.orderBy === 'PersistentVolumeClaim.StorageClass.Name'"
is-sorted-desc="$ctrl.state.orderBy === 'PersistentVolumeClaim.StorageClass.Name' && $ctrl.state.reverseOrder"
ng-click="$ctrl.changeOrderBy('PersistentVolumeClaim.StorageClass.Name')"
is-sorted="$ctrl.state.orderBy === 'PersistentVolumeClaim.storageClass.Name'"
is-sorted-desc="$ctrl.state.orderBy === 'PersistentVolumeClaim.storageClass.Name' && $ctrl.state.reverseOrder"
ng-click="$ctrl.changeOrderBy('PersistentVolumeClaim.storageClass.Name')"
></table-column-header>
</th>
<th>
@ -188,7 +188,7 @@
<span ng-if="!item.Applications.length">-</span>
</td>
<td>
{{ item.PersistentVolumeClaim.StorageClass.Name }}
{{ item.PersistentVolumeClaim.storageClass.Name }}
</td>
<td>
{{ item.PersistentVolumeClaim.Storage }}

@ -180,7 +180,7 @@ class KubernetesApplicationConverter {
persistedFolder.MountPath = matchingVolumeMount.mountPath;
if (volume.persistentVolumeClaim) {
persistedFolder.PersistentVolumeClaimName = volume.persistentVolumeClaim.claimName;
persistedFolder.persistentVolumeClaimName = volume.persistentVolumeClaim.claimName;
} else {
persistedFolder.HostPath = volume.hostPath.path;
}

@ -19,7 +19,7 @@ class KubernetesPersistentVolumeClaimConverter {
res.CreationDate = data.metadata.creationTimestamp;
res.Storage = `${data.spec.resources.requests.storage}B`;
res.AccessModes = data.spec.accessModes || [];
res.StorageClass = _.find(storageClasses, { Name: data.spec.storageClassName });
res.storageClass = _.find(storageClasses, { Name: data.spec.storageClassName });
res.Yaml = yaml ? yaml.data : '';
res.ApplicationOwner = data.metadata.labels ? data.metadata.labels[KubernetesPortainerApplicationOwnerLabel] : '';
res.ApplicationName = data.metadata.labels ? data.metadata.labels[KubernetesPortainerApplicationNameLabel] : '';
@ -31,30 +31,32 @@ class KubernetesPersistentVolumeClaimConverter {
* @param {KubernetesApplicationFormValues} formValues
*/
static applicationFormValuesToVolumeClaims(formValues) {
_.remove(formValues.PersistedFolders, (item) => item.NeedsDeletion);
_.remove(formValues.PersistedFolders, (item) => item.needsDeletion);
const res = _.map(formValues.PersistedFolders, (item) => {
const pvc = new KubernetesPersistentVolumeClaim();
if (!_.isEmpty(item.ExistingVolume)) {
const existantPVC = item.ExistingVolume.PersistentVolumeClaim;
if (!_.isEmpty(item.existingVolume)) {
const existantPVC = item.existingVolume.PersistentVolumeClaim;
pvc.Name = existantPVC.Name;
if (item.PersistentVolumeClaimName) {
pvc.PreviousName = item.PersistentVolumeClaimName;
if (item.persistentVolumeClaimName) {
pvc.PreviousName = item.persistentVolumeClaimName;
}
pvc.StorageClass = existantPVC.StorageClass;
pvc.storageClass = existantPVC.storageClass;
pvc.Storage = existantPVC.Storage.charAt(0);
pvc.CreationDate = existantPVC.CreationDate;
pvc.Id = existantPVC.Id;
} else {
if (item.PersistentVolumeClaimName) {
pvc.Name = item.PersistentVolumeClaimName;
pvc.PreviousName = item.PersistentVolumeClaimName;
if (item.persistentVolumeClaimName) {
pvc.Name = item.persistentVolumeClaimName;
if (!item.useNewVolume) {
pvc.PreviousName = item.persistentVolumeClaimName;
}
} else {
pvc.Name = formValues.Name + '-' + pvc.Name;
}
pvc.Storage = '' + item.Size + item.SizeUnit.charAt(0);
pvc.StorageClass = item.StorageClass;
pvc.Storage = '' + item.size + item.sizeUnit.charAt(0);
pvc.storageClass = item.storageClass;
}
pvc.MountPath = item.ContainerPath;
pvc.MountPath = item.containerPath;
pvc.Namespace = formValues.ResourcePool.Namespace.Name;
pvc.ApplicationOwner = formValues.ApplicationOwner;
pvc.ApplicationName = formValues.Name;
@ -68,7 +70,7 @@ class KubernetesPersistentVolumeClaimConverter {
res.metadata.name = pvc.Name;
res.metadata.namespace = pvc.Namespace;
res.spec.resources.requests.storage = pvc.Storage;
res.spec.storageClassName = pvc.StorageClass ? pvc.StorageClass.Name : '';
res.spec.storageClassName = pvc.storageClass ? pvc.storageClass.Name : '';
const accessModes = pvc.StorageClass && pvc.StorageClass.AccessModes ? pvc.StorageClass.AccessModes.map((accessMode) => storageClassToPVCAccessModes[accessMode]) : [];
res.spec.accessModes = accessModes;
res.metadata.labels.app = pvc.ApplicationName;

@ -5,7 +5,7 @@ import { KubernetesStorageClassCreatePayload } from 'Kubernetes/models/storage-c
class KubernetesStorageClassConverter {
/**
* API StorageClass to front StorageClass
* API storageClass to front storageClass
*/
static apiToStorageClass(data) {
const res = new KubernetesStorageClass();

@ -391,12 +391,12 @@ class KubernetesApplicationHelper {
/* #region PERSISTED FOLDERS FV <> VOLUMES */
static generatePersistedFoldersFormValuesFromPersistedFolders(persistedFolders, persistentVolumeClaims) {
const finalRes = _.map(persistedFolders, (folder) => {
const pvc = _.find(persistentVolumeClaims, (item) => _.startsWith(item.Name, folder.PersistentVolumeClaimName));
const res = new KubernetesApplicationPersistedFolderFormValue(pvc.StorageClass);
res.PersistentVolumeClaimName = folder.PersistentVolumeClaimName;
res.Size = parseInt(pvc.Storage, 10);
res.SizeUnit = pvc.Storage.slice(-2);
res.ContainerPath = folder.MountPath;
const pvc = _.find(persistentVolumeClaims, (item) => _.startsWith(item.Name, folder.persistentVolumeClaimName));
const res = new KubernetesApplicationPersistedFolderFormValue(pvc.storageClass);
res.persistentVolumeClaimName = folder.persistentVolumeClaimName;
res.size = pvc.Storage.slice(0, -2); // remove trailing units
res.sizeUnit = pvc.Storage.slice(-2);
res.containerPath = folder.MountPath;
return res;
});
return finalRes;
@ -420,11 +420,11 @@ class KubernetesApplicationHelper {
}
static hasRWOOnly(formValues) {
return _.find(formValues.PersistedFolders, (item) => item.StorageClass && _.isEqual(item.StorageClass.AccessModes, ['RWO']));
return _.find(formValues.PersistedFolders, (item) => item.storageClass && _.isEqual(item.storageClass.AccessModes, ['RWO']));
}
static hasRWX(claims) {
return _.find(claims, (item) => item.StorageClass && _.includes(item.StorageClass.AccessModes, 'RWX')) !== undefined;
return _.find(claims, (item) => item.storageClass && _.includes(item.storageClass.AccessModes, 'RWX')) !== undefined;
}
/* #endregion */

@ -7,8 +7,8 @@ class KubernetesResourceQuotaHelper {
static formatBytes(bytes, decimals = 0, base10 = true) {
const res = {
Size: 0,
SizeUnit: 'B',
size: 0,
sizeUnit: 'B',
};
if (bytes === 0) {
@ -22,8 +22,8 @@ class KubernetesResourceQuotaHelper {
const i = Math.floor(Math.log(bytes) / Math.log(k));
return {
Size: parseFloat((bytes / Math.pow(k, i)).toFixed(dm)),
SizeUnit: sizes[i],
size: parseFloat((bytes / Math.pow(k, i)).toFixed(dm)),
sizeUnit: sizes[i],
};
}
}

@ -81,20 +81,20 @@ export class KubernetesApplicationEnvironmentVariableFormValue {
* KubernetesApplicationPersistedFolderFormValue Model
*/
const _KubernetesApplicationPersistedFolderFormValue = Object.freeze({
PersistentVolumeClaimName: '', // will be empty for new volumes (create/edit app) and filled for existing ones (edit)
NeedsDeletion: false,
ContainerPath: '',
Size: '',
SizeUnit: 'GB',
StorageClass: {},
ExistingVolume: null,
UseNewVolume: true,
persistentVolumeClaimName: '', // will be empty for new volumes (create/edit app) and filled for existing ones (edit)
needsDeletion: false,
containerPath: '',
size: '',
sizeUnit: 'GB',
storageClass: {},
existingVolume: null,
useNewVolume: true,
});
export class KubernetesApplicationPersistedFolderFormValue {
constructor(storageClass) {
Object.assign(this, JSON.parse(JSON.stringify(_KubernetesApplicationPersistedFolderFormValue)));
this.StorageClass = storageClass;
this.storageClass = storageClass;
}
}

@ -69,7 +69,7 @@ export class HelmApplication {
*/
const _KubernetesApplicationPersistedFolder = Object.freeze({
MountPath: '',
PersistentVolumeClaimName: '',
persistentVolumeClaimName: '',
HostPath: '',
});

@ -8,7 +8,7 @@ const _KubernetesPersistentVolumeClaim = Object.freeze({
PreviousName: '',
Namespace: '',
Storage: 0,
StorageClass: {}, // KubernetesStorageClass
storageClass: {}, // KubernetesStorageClass
CreationDate: '',
ApplicationOwner: '',
AccessModes: [],

@ -6,7 +6,7 @@ import { NamespacesSelector } from '@/react/kubernetes/cluster/RegistryAccessVie
import { StorageAccessModeSelector } from '@/react/kubernetes/cluster/ConfigureView/ConfigureForm/StorageAccessModeSelector';
import { NamespaceAccessUsersSelector } from '@/react/kubernetes/namespaces/AccessView/NamespaceAccessUsersSelector';
import { RegistriesSelector } from '@/react/kubernetes/namespaces/components/RegistriesFormSection/RegistriesSelector';
import { KubeApplicationAccessPolicySelector } from '@/react/kubernetes/applications/CreateView/KubeApplicationAccessPolicySelector';
import { DataAccessPolicyFormSection } from '@/react/kubernetes/applications/CreateView/DataAccessPolicyFormSection';
import { KubeServicesForm } from '@/react/kubernetes/applications/CreateView/application-services/KubeServicesForm';
import { kubeServicesValidation } from '@/react/kubernetes/applications/CreateView/application-services/kubeServicesValidation';
import { KubeApplicationDeploymentTypeSelector } from '@/react/kubernetes/applications/CreateView/KubeApplicationDeploymentTypeSelector';
@ -28,6 +28,8 @@ import { kubeEnvVarValidationSchema } from '@/react/kubernetes/applications/Appl
import { SecretsFormSection } from '@/react/kubernetes/applications/components/ConfigurationsFormSection/SecretsFormSection';
import { configurationsValidationSchema } from '@/react/kubernetes/applications/components/ConfigurationsFormSection/configurationValidationSchema';
import { ConfigMapsFormSection } from '@/react/kubernetes/applications/components/ConfigurationsFormSection/ConfigMapsFormSection';
import { PersistedFoldersFormSection } from '@/react/kubernetes/applications/components/PersistedFoldersFormSection';
import { persistedFoldersValidation } from '@/react/kubernetes/applications/components/PersistedFoldersFormSection/persistedFoldersValidation';
import { EnvironmentVariablesFieldset } from '@@/form-components/EnvironmentVariablesFieldset';
@ -94,8 +96,8 @@ export const ngModule = angular
r2a(withUIRouter(withReactQuery(withCurrentUser(NodesDatatable))), [])
)
.component(
'kubeApplicationAccessPolicySelector',
r2a(KubeApplicationAccessPolicySelector, [
'dataAccessPolicyFormSection',
r2a(DataAccessPolicyFormSection, [
'value',
'onChange',
'isEdit',
@ -205,3 +207,17 @@ withFormValidation(
['values', 'onChange', 'namespace'],
configurationsValidationSchema
);
withFormValidation(
ngModule,
withUIRouter(withCurrentUser(withReactQuery(PersistedFoldersFormSection))),
'persistedFoldersFormSection',
[
'isEdit',
'applicationValues',
'isAddPersistentFolderButtonShown',
'initialValues',
'availableVolumes',
],
persistedFoldersValidation
);

@ -410,250 +410,25 @@
></secrets-form-section>
<!-- #endregion -->
<!-- #region PERSISTED FOLDERS -->
<div class="form-group">
<div class="col-sm-12 vertical-center mb-2 pt-2.5" style="margin-top: 5px">
<label class="control-label !pt-0 text-left !text-sm">Persisted folders</label>
</div>
<div class="col-sm-12 small text-muted vertical-center mt-1" ng-if="!ctrl.storageClassAvailable()">
<pr-icon icon="'info'" mode="'primary'"></pr-icon>
No storage option is available to persist data, contact your administrator to enable a storage option.
</div>
<div class="row" ng-if="ctrl.storageClassAvailable()">
<div class="col-sm-12" style="margin-top: 5px" ng-if="ctrl.allQuotasExhaustedAndNoVolumesAvailable()">
<span class="small text-muted vertical-center">
<pr-icon icon="'alert-circle'" mode="'warning'"></pr-icon>
This namespace has exhausted its storage capacity. Contact your administrator to expand the capacity of the namespace.
</span>
</div>
<div class="col-sm-12 form-inline" style="margin-top: 10px" ng-repeat="persistedFolder in ctrl.formValues.PersistedFolders">
<div style="margin-top: 2px">
<div class="input-group col-sm-3 input-group-sm" ng-class="{ striked: persistedFolder.NeedsDeletion }">
<span class="input-group-addon required">path in container</span>
<input
type="text"
class="form-control"
name="persisted_folder_path_{{ $index }}"
ng-model="persistedFolder.ContainerPath"
ng-change="ctrl.onChangePersistedFolderPath()"
ng-disabled="ctrl.isEditAndExistingPersistedFolder($index) || ctrl.formValues.Containers.length > 1"
placeholder="/data"
required
data-cy="k8sAppCreate-containerPathInput_{{ $index }}"
/>
</div>
<div
class="input-group col-sm-2 input-group-sm"
ng-if="
!ctrl.isEditAndExistingPersistedFolder($index) &&
ctrl.application.ApplicationType !== ctrl.ApplicationTypes.STATEFULSET &&
ctrl.formValues.Containers.length <= 1
"
>
<span class="btn-group btn-group-sm" ng-class="{ striked: persistedFolder.NeedsDeletion }">
<label
class="btn btn-light"
ng-model="persistedFolder.UseNewVolume"
uib-btn-radio="true"
ng-change="ctrl.useNewVolume($index)"
ng-disabled="ctrl.isNewVolumeButtonDisabled($index)"
>New volume</label
>
<label
class="btn btn-light"
ng-model="persistedFolder.UseNewVolume"
uib-btn-radio="false"
ng-change="ctrl.useExistingVolume($index)"
ng-disabled="ctrl.isExistingVolumeButtonDisabled()"
>Existing volume</label
>
</span>
</div>
<div class="input-group col-sm-3 input-group-sm" ng-class="{ striked: persistedFolder.NeedsDeletion }" ng-if="persistedFolder.UseNewVolume">
<span class="input-group-addon required">requested size</span>
<input
type="number"
class="form-control !rounded-none"
name="persisted_folder_size_{{ $index }}"
ng-model="persistedFolder.Size"
placeholder="20"
min="0"
required
ng-disabled="ctrl.isEditAndExistingPersistedFolder($index) || ctrl.formValues.Containers.length > 1"
ng-change="ctrl.onChangeVolumeRequestedSize()"
/>
<span class="input-group-addon !rounded-r-[5px] !p-0">
<select
class="form-control !h-[28px] w-12 !rounded-r-[5px] !border-none text-xs"
ng-model="persistedFolder.SizeUnit"
ng-style="{ height: '100%', cursor: ctrl.isEditAndExistingPersistedFolder($index) ? 'not-allowed' : 'auto' }"
ng-options="unit for unit in ctrl.state.availableSizeUnits"
ng-disabled="ctrl.isEditAndExistingPersistedFolder($index) || ctrl.formValues.Containers.length > 1"
ng-change="ctrl.onChangeVolumeRequestedSize()"
></select>
</span>
</div>
<div class="input-group col-sm-2 input-group-sm" ng-class="{ striked: persistedFolder.NeedsDeletion }" ng-if="persistedFolder.UseNewVolume">
<span class="input-group-addon">storage</span>
<select
ng-if="ctrl.hasMultipleStorageClassesAvailable()"
class="form-control"
ng-model="persistedFolder.StorageClass"
ng-options="storageClass as storageClass.Name for storageClass in ctrl.storageClasses"
ng-disabled="ctrl.state.isEdit || ctrl.formValues.Containers.length > 1"
data-cy="k8sAppCreate-storageSelect_{{ $index }}"
></select>
<input
ng-if="!ctrl.hasMultipleStorageClassesAvailable()"
type="text"
class="form-control"
disabled
ng-model="persistedFolder.StorageClass.Name"
data-cy="k8sAppCreate-storageClassNameInput_{{ $index }}"
/>
</div>
<div class="input-group col-sm-5 input-group-sm" ng-if="!persistedFolder.UseNewVolume" ng-class="{ striked: persistedFolder.NeedsDeletion }">
<span class="input-group-addon">volume</span>
<select
class="form-control"
name="existing_volumes_{{ $index }}"
ng-model="ctrl.formValues.PersistedFolders[$index].ExistingVolume"
ng-options="vol as vol.PersistentVolumeClaim.Name for vol in ctrl.availableVolumes"
ng-change="ctrl.onChangeExistingVolumeSelection()"
ng-disabled="ctrl.isEditAndExistingPersistedFolder($index) || ctrl.formValues.Containers.length > 1"
required
>
<option selected disabled hidden value="">Select a volume</option>
</select>
</div>
<div class="input-group col-sm-1 input-group-sm">
<div ng-if="!ctrl.isEditAndStatefulSet() && !ctrl.state.useExistingVolume[$index] && ctrl.formValues.Containers.length <= 1">
<button
ng-if="!persistedFolder.NeedsDeletion"
class="btn btn-sm btn-dangerlight !ml-0 h-[30px]"
type="button"
ng-click="ctrl.removePersistedFolder($index)"
data-cy="k8sAppCreate-rmPersistentFolderButton"
>
<pr-icon icon="'trash-2'" size="'md'"></pr-icon>
</button>
<button
ng-if="persistedFolder.NeedsDeletion"
class="btn btn-sm btn-primary"
type="button"
ng-click="ctrl.restorePersistedFolder($index)"
data-cy="k8sAppCreate-restorePersistentButton"
>
<pr-icon icon="'rotate-cw'"></pr-icon>
</button>
</div>
</div>
</div>
<div
class="flex flex-row gap-x-1"
ng-show="
kubernetesApplicationCreationForm['persisted_folder_path_' + $index].$invalid ||
ctrl.state.duplicates.persistedFolders.refs[$index] !== undefined ||
kubernetesApplicationCreationForm['persisted_folder_size_' + $index].$invalid ||
ctrl.state.exceeded.persistedFolders.refs[$index] !== undefined ||
kubernetesApplicationCreationForm['existing_volumes_' + $index].$invalid ||
ctrl.state.duplicates.existingVolumes.refs[$index] !== undefined
"
>
<div class="input-group col-sm-3 input-group-sm">
<div
class="small text-warning"
style="margin-top: 5px"
ng-show="
kubernetesApplicationCreationForm['persisted_folder_path_' + $index].$invalid || ctrl.state.duplicates.persistedFolders.refs[$index] !== undefined
"
>
<ng-messages for="kubernetesApplicationCreationForm['persisted_folder_path_' + $index].$error">
<p class="vertical-center" ng-message="required"><pr-icon icon="'alert-triangle'" mode="'warning'"></pr-icon> Path is required.</p>
</ng-messages>
<p class="vertical-center" ng-if="ctrl.state.duplicates.persistedFolders.refs[$index] !== undefined"
><pr-icon icon="'alert-triangle'" mode="'warning'"></pr-icon> This path is already defined.</p
>
</div>
</div>
<div class="input-group col-sm-offset-3 col-sm-3 input-group-sm">
<div
class="small text-warning"
style="margin-top: 5px"
ng-show="
kubernetesApplicationCreationForm['persisted_folder_size_' + $index].$invalid || ctrl.state.exceeded.persistedFolders.refs[$index] !== undefined
"
>
<ng-messages for="kubernetesApplicationCreationForm['persisted_folder_size_' + $index].$error">
<p class="vertical-center" ng-message="required"><pr-icon icon="'alert-triangle'" mode="'warning'"></pr-icon> Size is required.</p>
<p class="vertical-center" ng-message="min"><pr-icon icon="'alert-triangle'" mode="'warning'"></pr-icon> This value must be greater than zero.</p>
</ng-messages>
<p class="vertical-center" ng-if="ctrl.state.exceeded.persistedFolders.refs[$index] !== undefined">
<pr-icon icon="'alert-triangle'" mode="'warning'"></pr-icon>
You can only request up to
{{ ctrl.state.storages.availabilities[persistedFolder.StorageClass.Name] | kubernetesAppStorageRequestSizeHumanReadable }} for
{{ persistedFolder.StorageClass.Name }}
</p>
</div>
<div
class="small text-warning"
ng-show="kubernetesApplicationCreationForm['existing_volumes_' + $index].$invalid || ctrl.state.duplicates.existingVolumes.refs[$index] !== undefined"
>
<ng-messages for="kubernetesApplicationCreationForm['existing_volumes_' + $index].$error">
<p ng-message="required"><pr-icon icon="'alert-triangle'" mode="'warning'"></pr-icon> Volume is required.</p>
</ng-messages>
<p ng-if="ctrl.state.duplicates.existingVolumes.refs[$index] !== undefined"
><pr-icon icon="'alert-triangle'" mode="'warning'"></pr-icon> This volume is already used.</p
>
</div>
</div>
<div class="input-group col-sm-1 input-group-sm"> </div>
</div>
</div>
<div class="col-sm-12 mt-2">
<span
class="btn btn-primary btn-sm btn btn-sm btn-light mb-2 !ml-0"
ng-click="ctrl.addPersistedFolder()"
ng-if="ctrl.isAddPersistentFolderButtonShowed()"
data-cy="k8sAppCreate-addPersistentFolderButton"
>
<pr-icon icon="'plus'" size="'sm'"></pr-icon> Add persisted folder
</span>
</div>
</div>
</div>
<!-- #endregion -->
<persisted-folders-form-section
values="ctrl.formValues.PersistedFolders"
initial-values="ctrl.formValues.OriginalPersistedFolders"
on-change="(ctrl.onChangePersistedFolder)"
is-edit="ctrl.state.isEdit"
application-values="ctrl.formValues"
is-add-persistent-folder-button-shown="ctrl.isAddPersistentFolderButtonShown()"
available-volumes="ctrl.availableVolumes"
validation-data="{ namespaceQuotas: ctrl.formValues.ResourcePool.Quota, persistedFolders: ctrl.formValues.PersistedFolders, storageAvailabilities: ctrl.state.storages.availabilities }"
></persisted-folders-form-section>
<!-- #region DATA ACCESS POLICY -->
<div ng-if="ctrl.showDataAccessPolicySection()">
<div class="form-group">
<div class="col-sm-12">
<label class="control-label text-left">Data access policy</label>
</div>
</div>
<div class="form-group">
<div class="col-sm-12 small text-muted"> Specify how the data will be used across instances. </div>
</div>
<kube-application-access-policy-selector
<data-access-policy-form-section
value="ctrl.formValues.DataAccessPolicy"
on-change="(ctrl.onDataAccessPolicyChange)"
is-edit="ctrl.state.isEdit"
persisted-folders-use-existing-volumes="ctrl.state.persistedFoldersUseExistingVolumes"
></kube-application-access-policy-selector>
></data-access-policy-form-section>
</div>
<!-- #endregion -->

@ -153,6 +153,7 @@ class KubernetesCreateApplicationController {
this.onEnvironmentVariableChange = this.onEnvironmentVariableChange.bind(this);
this.onConfigMapsChange = this.onConfigMapsChange.bind(this);
this.onSecretsChange = this.onSecretsChange.bind(this);
this.onChangePersistedFolder = this.onChangePersistedFolder.bind(this);
}
/* #endregion */
@ -312,21 +313,21 @@ class KubernetesCreateApplicationController {
}
restorePersistedFolder(index) {
this.formValues.PersistedFolders[index].NeedsDeletion = false;
this.formValues.PersistedFolders[index].needsDeletion = false;
this.validatePersistedFolders();
}
resetPersistedFolders() {
this.formValues.PersistedFolders = _.forEach(this.formValues.PersistedFolders, (persistedFolder) => {
persistedFolder.ExistingVolume = null;
persistedFolder.UseNewVolume = true;
persistedFolder.existingVolume = null;
persistedFolder.useNewVolume = true;
});
this.validatePersistedFolders();
}
removePersistedFolder(index) {
if (this.state.isEdit && this.formValues.PersistedFolders[index].PersistentVolumeClaimName) {
this.formValues.PersistedFolders[index].NeedsDeletion = true;
if (this.state.isEdit && this.formValues.PersistedFolders[index].persistentVolumeClaimName) {
this.formValues.PersistedFolders[index].needsDeletion = true;
} else {
this.formValues.PersistedFolders.splice(index, 1);
}
@ -334,15 +335,15 @@ class KubernetesCreateApplicationController {
}
useNewVolume(index) {
this.formValues.PersistedFolders[index].UseNewVolume = true;
this.formValues.PersistedFolders[index].ExistingVolume = null;
this.state.persistedFoldersUseExistingVolumes = !_.reduce(this.formValues.PersistedFolders, (acc, pf) => acc && pf.UseNewVolume, true);
this.formValues.PersistedFolders[index].useNewVolume = true;
this.formValues.PersistedFolders[index].existingVolume = null;
this.state.persistedFoldersUseExistingVolumes = _.some(this.formValues.PersistedFolders, { useNewVolume: false });
this.validatePersistedFolders();
}
useExistingVolume(index) {
this.formValues.PersistedFolders[index].UseNewVolume = false;
this.state.persistedFoldersUseExistingVolumes = _.find(this.formValues.PersistedFolders, { UseNewVolume: false }) ? true : false;
this.formValues.PersistedFolders[index].useNewVolume = false;
this.state.persistedFoldersUseExistingVolumes = _.some(this.formValues.PersistedFolders, { useNewVolume: false });
if (this.formValues.DataAccessPolicy === this.ApplicationDataAccessPolicies.ISOLATED) {
this.formValues.DataAccessPolicy = this.ApplicationDataAccessPolicies.SHARED;
this.resetDeploymentType();
@ -360,22 +361,26 @@ class KubernetesCreateApplicationController {
onChangePersistedFolderPath() {
this.state.duplicates.persistedFolders.refs = KubernetesFormValidationHelper.getDuplicates(
_.map(this.formValues.PersistedFolders, (persistedFolder) => {
if (persistedFolder.NeedsDeletion) {
if (persistedFolder.needsDeletion) {
return undefined;
}
return persistedFolder.ContainerPath;
return persistedFolder.containerPath;
})
);
this.state.duplicates.persistedFolders.hasRefs = Object.keys(this.state.duplicates.persistedFolders.refs).length > 0;
}
onChangePersistedFolder(values) {
this.formValues.PersistedFolders = values;
}
onChangeExistingVolumeSelection() {
this.state.duplicates.existingVolumes.refs = KubernetesFormValidationHelper.getDuplicates(
_.map(this.formValues.PersistedFolders, (persistedFolder) => {
if (persistedFolder.NeedsDeletion) {
if (persistedFolder.needsDeletion) {
return undefined;
}
return persistedFolder.ExistingVolume ? persistedFolder.ExistingVolume.PersistentVolumeClaim.Name : '';
return persistedFolder.existingVolume ? persistedFolder.existingVolume.PersistentVolumeClaim.Name : '';
})
);
this.state.duplicates.existingVolumes.hasRefs = Object.keys(this.state.duplicates.existingVolumes.refs).length > 0;
@ -518,8 +523,8 @@ class KubernetesCreateApplicationController {
for (let i = 0; i < this.formValues.PersistedFolders.length; i++) {
const folder = this.formValues.PersistedFolders[i];
if (folder.StorageClass && _.isEqual(folder.StorageClass.AccessModes, ['RWO'])) {
storageOptions.push(folder.StorageClass.Name);
if (folder.storageClass && _.isEqual(folder.storageClass.AccessModes, ['RWO'])) {
storageOptions.push(folder.storageClass.Name);
} else {
storageOptions.push('<no storage option available>');
}
@ -612,7 +617,7 @@ class KubernetesCreateApplicationController {
/* #region PERSISTED FOLDERS */
/* #region BUTTONS STATES */
isAddPersistentFolderButtonShowed() {
isAddPersistentFolderButtonShown() {
return !this.isEditAndStatefulSet() && this.formValues.Containers.length <= 1;
}
@ -630,7 +635,7 @@ class KubernetesCreateApplicationController {
}
isEditAndExistingPersistedFolder(index) {
return this.state.isEdit && this.formValues.PersistedFolders[index].PersistentVolumeClaimName;
return this.state.isEdit && this.formValues.PersistedFolders[index].persistentVolumeClaimName;
}
/* #endregion */
@ -781,7 +786,7 @@ class KubernetesCreateApplicationController {
this.volumes = volumes;
const filteredVolumes = _.filter(this.volumes, (volume) => {
const isUnused = !KubernetesVolumeHelper.isUsed(volume);
const isRWX = volume.PersistentVolumeClaim.StorageClass && _.includes(volume.PersistentVolumeClaim.StorageClass.AccessModes, 'RWX');
const isRWX = volume.PersistentVolumeClaim.storageClass && _.includes(volume.PersistentVolumeClaim.storageClass.AccessModes, 'RWX');
return isUnused || isRWX;
});
this.availableVolumes = filteredVolumes;
@ -873,7 +878,11 @@ class KubernetesCreateApplicationController {
this.state.actionInProgress = true;
await this.KubernetesApplicationService.patch(this.savedFormValues, this.formValues, false, this.originalServicePorts);
this.Notifications.success('Success', 'Request to update application successfully submitted');
this.$state.go('kubernetes.applications.application', { name: this.application.Name, namespace: this.application.ResourcePool });
this.$state.go(
'kubernetes.applications.application',
{ name: this.application.Name, namespace: this.application.ResourcePool, endpointId: this.endpoint.Id },
{ inherit: false }
);
} catch (err) {
this.Notifications.error('Failure', err, 'Unable to update application');
} finally {
@ -1087,13 +1096,14 @@ class KubernetesCreateApplicationController {
if (this.application.ApplicationType !== KubernetesApplicationTypes.STATEFULSET) {
_.forEach(this.formValues.PersistedFolders, (persistedFolder) => {
const volume = _.find(this.availableVolumes, ['PersistentVolumeClaim.Name', persistedFolder.PersistentVolumeClaimName]);
const volume = _.find(this.availableVolumes, ['PersistentVolumeClaim.Name', persistedFolder.persistentVolumeClaimName]);
if (volume) {
persistedFolder.UseNewVolume = false;
persistedFolder.ExistingVolume = volume;
persistedFolder.useNewVolume = false;
persistedFolder.existingVolume = volume;
}
});
}
this.formValues.OriginalPersistedFolders = this.formValues.PersistedFolders;
await this.refreshNamespaceData(namespace);
} else {
this.formValues.AutoScaler = KubernetesApplicationHelper.generateAutoScalerFormValueFromHorizontalPodAutoScaler(null, this.formValues.ReplicaCount);

@ -91,7 +91,7 @@
</div>
</td>
<td>{{ item.Name }}</td>
<td>{{ item.Size }}</td>
<td>{{ item.size }}</td>
</tr>
<tr
dir-paginate-end

@ -81,9 +81,9 @@
<table-column-header
col-title="'Usage'"
can-sort="true"
is-sorted="$ctrl.state.orderBy === 'Size'"
is-sorted-desc="$ctrl.state.orderBy === 'Size' && $ctrl.state.reverseOrder"
ng-click="$ctrl.changeOrderBy('Size')"
is-sorted="$ctrl.state.orderBy === 'size'"
is-sorted-desc="$ctrl.state.orderBy === 'size' && $ctrl.state.reverseOrder"
ng-click="$ctrl.changeOrderBy('size')"
></table-column-header>
</th>
</tr>
@ -102,7 +102,7 @@
</div>
</td>
<td>{{ item.Name }}</td>
<td>{{ item.Size }}</td>
<td>{{ item.size }}</td>
</tr>
<tr
dir-paginate-end

@ -50,7 +50,7 @@
</tr>
<tr>
<td>Storage Class</td>
<td data-cy="k8sVolDetail-volStorageClassname">{{ ctrl.volume.PersistentVolumeClaim.StorageClass.Name }}</td>
<td data-cy="k8sVolDetail-volStorageClassname">{{ ctrl.volume.PersistentVolumeClaim.storageClass.Name }}</td>
</tr>
<tr>
<td>Access Modes</td>
@ -69,7 +69,7 @@
<tr>
<td>Provisioner</td>
<td data-cy="k8sVolDetail-volProvisioner">{{
ctrl.volume.PersistentVolumeClaim.StorageClass.Provisioner ? ctrl.volume.PersistentVolumeClaim.StorageClass.Provisioner : '-'
ctrl.volume.PersistentVolumeClaim.storageClass.Provisioner ? ctrl.volume.PersistentVolumeClaim.storageClass.Provisioner : '-'
}}</td>
</tr>
<tr>
@ -77,14 +77,14 @@
<td data-cy="k8sVolDetail-volCreatedAt">{{ ctrl.volume.PersistentVolumeClaim.CreationDate | getisodate }}</td>
</tr>
<tr>
<td>Size</td>
<td>size</td>
<td ng-if="!ctrl.state.increaseSize">
{{ ctrl.volume.PersistentVolumeClaim.Storage }}
<button
type="button"
class="btn btn-sm btn-primary"
ng-click="ctrl.state.increaseSize = true"
ng-if="ctrl.volume.PersistentVolumeClaim.StorageClass.AllowVolumeExpansion"
ng-if="ctrl.volume.PersistentVolumeClaim.storageClass.AllowVolumeExpansion"
data-cy="k8sVolDetail-increaseSizeButton"
>Increase size</button
>

@ -186,12 +186,14 @@ class KubernetesVolumeController {
try {
await this.getVolume();
await this.getEvents();
this.state.volumeSharedAccessPolicies = this.volume.PersistentVolumeClaim.AccessModes;
let policies = KubernetesStorageClassAccessPolicies();
this.state.volumeSharedAccessPolicyTooltips = this.state.volumeSharedAccessPolicies.map((policy) => {
const matchingPolicy = policies.find((p) => p.Name === policy);
return matchingPolicy ? matchingPolicy.Description : undefined;
});
if (this.volume.PersistentVolumeClaim.storageClass !== undefined) {
this.state.volumeSharedAccessPolicies = this.volume.PersistentVolumeClaim.AccessModes;
let policies = KubernetesStorageClassAccessPolicies();
this.state.volumeSharedAccessPolicyTooltips = this.state.volumeSharedAccessPolicies.map((policy) => {
const matchingPolicy = policies.find((p) => p.Name === policy);
return matchingPolicy ? matchingPolicy.Description : undefined;
});
}
} catch (err) {
this.Notifications.error('Failure', err, 'Unable to load view data');
} finally {

@ -7,9 +7,9 @@ import { confirmDelete } from '@@/modals/confirm';
function buildStorages(storages, volumes) {
_.forEach(storages, (s) => {
const filteredVolumes = _.filter(volumes, ['PersistentVolumeClaim.StorageClass.Name', s.Name, 'PersistentVolumeClaim.StorageClass.Provisioner', s.Provisioner]);
const filteredVolumes = _.filter(volumes, ['PersistentVolumeClaim.storageClass.Name', s.Name, 'PersistentVolumeClaim.storageClass.Provisioner', s.Provisioner]);
s.Volumes = filteredVolumes;
s.Size = computeSize(filteredVolumes);
s.size = computeSize(filteredVolumes);
});
return storages;
}
@ -17,7 +17,7 @@ function buildStorages(storages, volumes) {
function computeSize(volumes) {
const size = _.sumBy(volumes, (v) => filesizeParser(v.PersistentVolumeClaim.Storage, { base: 10 }));
const format = KubernetesResourceQuotaHelper.formatBytes(size);
return `${format.Size}${format.SizeUnit}`;
return `${format.size}${format.sizeUnit}`;
}
class KubernetesVolumesController {

@ -129,10 +129,17 @@ function createFormValidatorController<TFormModel, TData = never>(
});
}
async $onChanges(changes: { values?: { currentValue: TFormModel } }) {
async $onChanges(changes: {
values?: { currentValue: TFormModel };
validationData?: { currentValue: TData };
}) {
if (changes.values) {
await this.runValidation(changes.values.currentValue);
}
// also run validation if validationData changes
if (changes.validationData) {
await this.runValidation(this.values!);
}
}
};
}

@ -9,6 +9,7 @@ import styles from './ButtonSelector.module.css';
export interface Option<T> {
value: T;
label?: ReactNode;
disabled?: boolean;
}
interface Props<T> {
@ -43,7 +44,7 @@ export function ButtonSelector<T extends string | number | boolean>({
key={option.value.toString()}
selected={value === option.value}
onChange={() => onChange(option.value)}
disabled={disabled}
disabled={disabled || option.disabled}
readOnly={readOnly}
>
{option.label || option.value.toString()}

@ -10,6 +10,7 @@ interface Props {
titleSize?: 'sm' | 'md' | 'lg';
isFoldable?: boolean;
defaultFolded?: boolean;
titleClassName?: string;
}
export function FormSection({
@ -18,6 +19,7 @@ export function FormSection({
children,
isFoldable = false,
defaultFolded = isFoldable,
titleClassName,
}: PropsWithChildren<Props>) {
const [isExpanded, setIsExpanded] = useState(!defaultFolded);
@ -26,6 +28,7 @@ export function FormSection({
<FormSectionTitle
htmlFor={isFoldable ? `foldingButton${title}` : ''}
titleSize={titleSize}
className={titleClassName}
>
{isFoldable && (
<button

@ -4,6 +4,7 @@ import { PropsWithChildren } from 'react';
interface Props {
htmlFor?: string;
titleSize?: 'sm' | 'md' | 'lg';
className?: string;
}
const tailwindTitleSize = {
@ -16,6 +17,7 @@ export function FormSectionTitle({
children,
htmlFor,
titleSize = 'md',
className,
}: PropsWithChildren<Props>) {
if (htmlFor) {
return (
@ -23,7 +25,8 @@ export function FormSectionTitle({
htmlFor={htmlFor}
className={clsx(
'col-sm-12 mb-2 mt-1 flex cursor-pointer items-center pl-0 font-medium',
tailwindTitleSize[titleSize]
tailwindTitleSize[titleSize],
className
)}
>
{children}
@ -34,7 +37,8 @@ export function FormSectionTitle({
<div
className={clsx(
'col-sm-12 mb-2 mt-4 pl-0 font-medium',
tailwindTitleSize[titleSize]
tailwindTitleSize[titleSize],
className
)}
>
{children}

@ -6,11 +6,13 @@ import { useInputGroupContext } from './InputGroup';
type BaseProps<TProps> = {
as?: ComponentType<TProps> | string;
required?: boolean;
className?: string;
};
export function InputGroupAddon<TProps>({
children,
as = 'span',
className,
required,
...props
}: PropsWithChildren<BaseProps<TProps> & TProps>) {
@ -19,7 +21,7 @@ export function InputGroupAddon<TProps>({
return (
<Component
className={clsx('input-group-addon', required && 'required')}
className={clsx('input-group-addon', required && 'required', className)}
// eslint-disable-next-line react/jsx-props-no-spreading
{...props}
>

@ -11,7 +11,7 @@ interface Props {
onChange(value: number): void;
}
export function KubeApplicationAccessPolicySelector({
export function DataAccessPolicyFormSection({
isEdit,
persistedFoldersUseExistingVolumes,
value,

@ -108,7 +108,7 @@ const queryKeys = {
};
// useQuery to get a list of all applications from an array of namespaces
export function useApplicationsForCluster(
export function useApplicationsQuery(
environemtId: EnvironmentId,
namespaces?: string[]
) {

@ -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;
};
};

@ -11,6 +11,11 @@ import {
import { Pod, PodList } from 'kubernetes-types/core/v1';
import { RawExtension } from 'kubernetes-types/runtime';
export type ApplicationFormValues = {
Containers: Array<unknown>;
ApplicationType: number; // KubernetesApplicationTypes
};
export type Application = Deployment | DaemonSet | StatefulSet | Pod;
// Revisions are have the previous application state and are used for rolling back applications to their previous state.

@ -25,7 +25,7 @@ import { IngressControllerClassMap } from '../../ingressClass/types';
import { useIsRBACEnabledQuery } from '../../getIsRBACEnabled';
import { getIngressClassesFormValues } from '../../ingressClass/IngressClassDatatable/utils';
import { useStorageClassesFormValues } from './useStorageClassesFormValues';
import { useStorageClassesFormValues } from './useStorageClasses';
import { ConfigureFormValues, StorageClassFormValues } from './types';
import { configureValidationSchema } from './validation';
import { RBACAlert } from './RBACAlert';

@ -5,7 +5,7 @@ import { Switch } from '@@/form-components/SwitchField/Switch';
import { StorageAccessModeSelector } from './StorageAccessModeSelector';
import { ConfigureFormValues, StorageClassFormValues } from './types';
import { availableStorageClassPolicies } from './useStorageClassesFormValues';
import { availableStorageClassPolicies } from './useStorageClasses';
type Props = {
storageClassValues: StorageClassFormValues[];

@ -26,9 +26,31 @@ export const availableStorageClassPolicies = [
},
];
export function useStorageClassesFormValues(
environment: Environment | null | undefined
) {
export function useStorageClasses(environment?: Environment | null) {
return useQuery(
[
'environments',
environment?.Id,
'kubernetes',
'storageclasses',
// include the storage classes in the cache key to force a refresh when the storage classes change in the environment object
JSON.stringify(environment?.Kubernetes.Configuration.StorageClasses),
],
async () => {
if (!environment) {
return [];
}
const storageClasses = await getStorageClasses(environment.Id);
return storageClasses;
},
{
...withError('Failure', `Unable to get Storage Classes`),
enabled: !!environment,
}
);
}
export function useStorageClassesFormValues(environment?: Environment | null) {
return useQuery(
[
'environments',

@ -8,7 +8,7 @@ import { DefaultDatatableSettings } from '@/react/kubernetes/datatables/DefaultD
import { createStore } from '@/react/kubernetes/datatables/default-kube-datatable-store';
import { isSystemNamespace } from '@/react/kubernetes/namespaces/utils';
import { SystemResourceDescription } from '@/react/kubernetes/datatables/SystemResourceDescription';
import { useApplicationsForCluster } from '@/react/kubernetes/applications/application.queries';
import { useApplicationsQuery } from '@/react/kubernetes/applications/application.queries';
import { Application } from '@/react/kubernetes/applications/types';
import { pluralize } from '@/portainer/helpers/strings';
import { useNamespacesQuery } from '@/react/kubernetes/namespaces/queries/useNamespacesQuery';
@ -54,8 +54,10 @@ export function ConfigMapsDatatable() {
autoRefreshRate: tableState.autoRefreshRate * 1000,
}
);
const { data: applications, ...applicationsQuery } =
useApplicationsForCluster(environmentId, namespaceNames);
const { data: applications, ...applicationsQuery } = useApplicationsQuery(
environmentId,
namespaceNames
);
const filteredConfigMaps = useMemo(
() =>

@ -8,7 +8,7 @@ import { DefaultDatatableSettings } from '@/react/kubernetes/datatables/DefaultD
import { createStore } from '@/react/kubernetes/datatables/default-kube-datatable-store';
import { isSystemNamespace } from '@/react/kubernetes/namespaces/utils';
import { SystemResourceDescription } from '@/react/kubernetes/datatables/SystemResourceDescription';
import { useApplicationsForCluster } from '@/react/kubernetes/applications/application.queries';
import { useApplicationsQuery } from '@/react/kubernetes/applications/application.queries';
import { Application } from '@/react/kubernetes/applications/types';
import { pluralize } from '@/portainer/helpers/strings';
import { useNamespacesQuery } from '@/react/kubernetes/namespaces/queries/useNamespacesQuery';
@ -54,8 +54,10 @@ export function SecretsDatatable() {
autoRefreshRate: tableState.autoRefreshRate * 1000,
}
);
const { data: applications, ...applicationsQuery } =
useApplicationsForCluster(environmentId, namespaceNames);
const { data: applications, ...applicationsQuery } = useApplicationsQuery(
environmentId,
namespaceNames
);
const filteredSecrets = useMemo(
() =>

@ -8,8 +8,8 @@ import { DashboardGrid } from '@@/DashboardItem/DashboardGrid';
import { DashboardItem } from '@@/DashboardItem/DashboardItem';
import { PageHeader } from '@@/PageHeader';
import { useApplicationsForCluster } from '../applications/application.queries';
import { usePVCsForCluster } from '../volumes/queries';
import { useApplicationsQuery } from '../applications/application.queries';
import { usePVCsQuery } from '../volumes/usePVCsQuery';
import { useServicesForCluster } from '../services/service';
import { useIngresses } from '../ingresses/queries';
import { useConfigMapsForCluster } from '../configs/configmap.service';
@ -24,9 +24,11 @@ export function DashboardView() {
const { data: namespaces, ...namespacesQuery } =
useNamespacesQuery(environmentId);
const namespaceNames = namespaces && Object.keys(namespaces);
const { data: applications, ...applicationsQuery } =
useApplicationsForCluster(environmentId, namespaceNames);
const { data: pvcs, ...pvcsQuery } = usePVCsForCluster(
const { data: applications, ...applicationsQuery } = useApplicationsQuery(
environmentId,
namespaceNames
);
const { data: pvcs, ...pvcsQuery } = usePVCsQuery(
environmentId,
namespaceNames
);

@ -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[]
) {
const pvcs = await Promise.all(
namespaces.map((namespace) => getPVCs(environmentId, namespace))
return useQuery(
['environments', environmentId, 'kubernetes', 'pvcs'],
async () => {
if (!namespaces) {
return [];
}
const pvcs = await Promise.all(
namespaces?.map((namespace) => getPVCs(environmentId, namespace))
);
return pvcs.flat();
},
{
...withError('Unable to retrieve perrsistent volume claims'),
enabled: !!namespaces,
}
);
return pvcs.flat();
}
// get all persistent volume claims for a namespace
Loading…
Cancel
Save