From e07ee05ee7a1fe94a62c6a80378e10adfbc82e48 Mon Sep 17 00:00:00 2001
From: Ali <83188384+testA113@users.noreply.github.com>
Date: Wed, 3 Jan 2024 09:46:26 +1300
Subject: [PATCH] refactor(app): persisted folders form section [EE-6235]
(#10693)
* refactor(app): persisted folder section [EE-6235]
---
.../volumes-datatable/volumesDatatable.html | 8 +-
app/kubernetes/converters/application.js | 2 +-
.../converters/persistentVolumeClaim.js | 30 ++-
app/kubernetes/converters/storageClass.js | 2 +-
app/kubernetes/helpers/application/index.js | 16 +-
app/kubernetes/helpers/resourceQuotaHelper.js | 8 +-
.../models/application/formValues.js | 18 +-
.../models/application/models/index.js | 2 +-
app/kubernetes/models/volume/models.js | 2 +-
app/kubernetes/react/components/index.ts | 22 +-
.../create/createApplication.html | 249 +-----------------
.../create/createApplicationController.js | 56 ++--
.../ingresses-datatable/template.html | 2 +-
.../volumes-storages-datatable/template.html | 8 +-
app/kubernetes/views/volumes/edit/volume.html | 8 +-
.../views/volumes/edit/volumeController.js | 14 +-
.../views/volumes/volumesController.js | 6 +-
app/react-tools/withFormValidation.ts | 9 +-
.../ButtonSelector/ButtonSelector.tsx | 3 +-
.../FormSection/FormSection.tsx | 3 +
.../FormSectionTitle/FormSectionTitle.tsx | 8 +-
.../InputGroup/InputGroupAddon.tsx | 4 +-
...or.tsx => DataAccessPolicyFormSection.tsx} | 2 +-
.../applications/application.queries.ts | 2 +-
.../PersistedFolderItem.tsx | 240 +++++++++++++++++
.../PersistedFoldersFormSection.tsx | 127 +++++++++
.../PersistedFoldersFormSection/index.ts | 1 +
.../persistedFoldersValidation.ts | 113 ++++++++
.../PersistedFoldersFormSection/types.ts | 28 ++
app/react/kubernetes/applications/types.ts | 5 +
.../ConfigureForm/ConfigureForm.tsx | 2 +-
.../ConfigureForm/StorageClassDatatable.tsx | 2 +-
...ssesFormValues.ts => useStorageClasses.ts} | 28 +-
.../ConfigMapsDatatable.tsx | 8 +-
.../SecretsDatatable/SecretsDatatable.tsx | 8 +-
.../kubernetes/dashboard/DashboardView.tsx | 12 +-
app/react/kubernetes/volumes/.keep | 0
app/react/kubernetes/volumes/queries.ts | 21 --
.../volumes/{service.ts => usePVCsQuery.ts} | 27 +-
39 files changed, 732 insertions(+), 374 deletions(-)
rename app/react/kubernetes/applications/CreateView/{KubeApplicationAccessPolicySelector.tsx => DataAccessPolicyFormSection.tsx} (97%)
create mode 100644 app/react/kubernetes/applications/components/PersistedFoldersFormSection/PersistedFolderItem.tsx
create mode 100644 app/react/kubernetes/applications/components/PersistedFoldersFormSection/PersistedFoldersFormSection.tsx
create mode 100644 app/react/kubernetes/applications/components/PersistedFoldersFormSection/index.ts
create mode 100644 app/react/kubernetes/applications/components/PersistedFoldersFormSection/persistedFoldersValidation.ts
create mode 100644 app/react/kubernetes/applications/components/PersistedFoldersFormSection/types.ts
rename app/react/kubernetes/cluster/ConfigureView/ConfigureForm/{useStorageClassesFormValues.ts => useStorageClasses.ts} (80%)
delete mode 100644 app/react/kubernetes/volumes/.keep
delete mode 100644 app/react/kubernetes/volumes/queries.ts
rename app/react/kubernetes/volumes/{service.ts => usePVCsQuery.ts} (53%)
diff --git a/app/kubernetes/components/datatables/volumes-datatable/volumesDatatable.html b/app/kubernetes/components/datatables/volumes-datatable/volumesDatatable.html
index 7d2691155..1563567a2 100644
--- a/app/kubernetes/components/datatables/volumes-datatable/volumesDatatable.html
+++ b/app/kubernetes/components/datatables/volumes-datatable/volumesDatatable.html
@@ -137,9 +137,9 @@
@@ -188,7 +188,7 @@
-
- {{ item.PersistentVolumeClaim.StorageClass.Name }}
+ {{ item.PersistentVolumeClaim.storageClass.Name }}
{{ item.PersistentVolumeClaim.Storage }}
diff --git a/app/kubernetes/converters/application.js b/app/kubernetes/converters/application.js
index 01b224293..7555c66b3 100644
--- a/app/kubernetes/converters/application.js
+++ b/app/kubernetes/converters/application.js
@@ -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;
}
diff --git a/app/kubernetes/converters/persistentVolumeClaim.js b/app/kubernetes/converters/persistentVolumeClaim.js
index 637df6374..363c7365e 100644
--- a/app/kubernetes/converters/persistentVolumeClaim.js
+++ b/app/kubernetes/converters/persistentVolumeClaim.js
@@ -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;
diff --git a/app/kubernetes/converters/storageClass.js b/app/kubernetes/converters/storageClass.js
index 7a2afca0b..7a4108d31 100644
--- a/app/kubernetes/converters/storageClass.js
+++ b/app/kubernetes/converters/storageClass.js
@@ -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();
diff --git a/app/kubernetes/helpers/application/index.js b/app/kubernetes/helpers/application/index.js
index 9440b910a..46d780776 100644
--- a/app/kubernetes/helpers/application/index.js
+++ b/app/kubernetes/helpers/application/index.js
@@ -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 */
diff --git a/app/kubernetes/helpers/resourceQuotaHelper.js b/app/kubernetes/helpers/resourceQuotaHelper.js
index 46d4cf033..e713ee748 100644
--- a/app/kubernetes/helpers/resourceQuotaHelper.js
+++ b/app/kubernetes/helpers/resourceQuotaHelper.js
@@ -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],
};
}
}
diff --git a/app/kubernetes/models/application/formValues.js b/app/kubernetes/models/application/formValues.js
index 243765758..aa4f50563 100644
--- a/app/kubernetes/models/application/formValues.js
+++ b/app/kubernetes/models/application/formValues.js
@@ -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;
}
}
diff --git a/app/kubernetes/models/application/models/index.js b/app/kubernetes/models/application/models/index.js
index 0391a7f86..1e562c252 100644
--- a/app/kubernetes/models/application/models/index.js
+++ b/app/kubernetes/models/application/models/index.js
@@ -69,7 +69,7 @@ export class HelmApplication {
*/
const _KubernetesApplicationPersistedFolder = Object.freeze({
MountPath: '',
- PersistentVolumeClaimName: '',
+ persistentVolumeClaimName: '',
HostPath: '',
});
diff --git a/app/kubernetes/models/volume/models.js b/app/kubernetes/models/volume/models.js
index bb140dcaf..2163412e8 100644
--- a/app/kubernetes/models/volume/models.js
+++ b/app/kubernetes/models/volume/models.js
@@ -8,7 +8,7 @@ const _KubernetesPersistentVolumeClaim = Object.freeze({
PreviousName: '',
Namespace: '',
Storage: 0,
- StorageClass: {}, // KubernetesStorageClass
+ storageClass: {}, // KubernetesStorageClass
CreationDate: '',
ApplicationOwner: '',
AccessModes: [],
diff --git a/app/kubernetes/react/components/index.ts b/app/kubernetes/react/components/index.ts
index a6bc99a7f..f0ad11e94 100644
--- a/app/kubernetes/react/components/index.ts
+++ b/app/kubernetes/react/components/index.ts
@@ -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
+);
diff --git a/app/kubernetes/views/applications/create/createApplication.html b/app/kubernetes/views/applications/create/createApplication.html
index 1a597a51d..f559bb292 100644
--- a/app/kubernetes/views/applications/create/createApplication.html
+++ b/app/kubernetes/views/applications/create/createApplication.html
@@ -410,250 +410,25 @@
>
-
-
-
+
diff --git a/app/kubernetes/views/applications/create/createApplicationController.js b/app/kubernetes/views/applications/create/createApplicationController.js
index 10c33e1ea..415c6acf1 100644
--- a/app/kubernetes/views/applications/create/createApplicationController.js
+++ b/app/kubernetes/views/applications/create/createApplicationController.js
@@ -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('');
}
@@ -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);
diff --git a/app/kubernetes/views/resource-pools/edit/components/ingresses-datatable/template.html b/app/kubernetes/views/resource-pools/edit/components/ingresses-datatable/template.html
index 84db9d3f8..b36cb7a43 100644
--- a/app/kubernetes/views/resource-pools/edit/components/ingresses-datatable/template.html
+++ b/app/kubernetes/views/resource-pools/edit/components/ingresses-datatable/template.html
@@ -91,7 +91,7 @@
{{ item.Name }}
- {{ item.Size }}
+ {{ item.size }}
@@ -102,7 +102,7 @@
{{ item.Name }}
- {{ item.Size }}
+ {{ item.size }}
Storage Class
- {{ ctrl.volume.PersistentVolumeClaim.StorageClass.Name }}
+ {{ ctrl.volume.PersistentVolumeClaim.storageClass.Name }}
Access Modes
@@ -69,7 +69,7 @@
Provisioner
{{
- ctrl.volume.PersistentVolumeClaim.StorageClass.Provisioner ? ctrl.volume.PersistentVolumeClaim.StorageClass.Provisioner : '-'
+ ctrl.volume.PersistentVolumeClaim.storageClass.Provisioner ? ctrl.volume.PersistentVolumeClaim.storageClass.Provisioner : '-'
}}
@@ -77,14 +77,14 @@
{{ ctrl.volume.PersistentVolumeClaim.CreationDate | getisodate }}
- Size
+ size
{{ ctrl.volume.PersistentVolumeClaim.Storage }}
Increase size
diff --git a/app/kubernetes/views/volumes/edit/volumeController.js b/app/kubernetes/views/volumes/edit/volumeController.js
index 09fb1faf5..f9e9c12f6 100644
--- a/app/kubernetes/views/volumes/edit/volumeController.js
+++ b/app/kubernetes/views/volumes/edit/volumeController.js
@@ -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 {
diff --git a/app/kubernetes/views/volumes/volumesController.js b/app/kubernetes/views/volumes/volumesController.js
index 6e0ab6152..b21062a6d 100644
--- a/app/kubernetes/views/volumes/volumesController.js
+++ b/app/kubernetes/views/volumes/volumesController.js
@@ -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 {
diff --git a/app/react-tools/withFormValidation.ts b/app/react-tools/withFormValidation.ts
index 53e04de81..71af58d35 100644
--- a/app/react-tools/withFormValidation.ts
+++ b/app/react-tools/withFormValidation.ts
@@ -129,10 +129,17 @@ function createFormValidatorController(
});
}
- 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!);
+ }
}
};
}
diff --git a/app/react/components/form-components/ButtonSelector/ButtonSelector.tsx b/app/react/components/form-components/ButtonSelector/ButtonSelector.tsx
index 44a9301e5..1a1e10d6f 100644
--- a/app/react/components/form-components/ButtonSelector/ButtonSelector.tsx
+++ b/app/react/components/form-components/ButtonSelector/ButtonSelector.tsx
@@ -9,6 +9,7 @@ import styles from './ButtonSelector.module.css';
export interface Option {
value: T;
label?: ReactNode;
+ disabled?: boolean;
}
interface Props {
@@ -43,7 +44,7 @@ export function ButtonSelector({
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()}
diff --git a/app/react/components/form-components/FormSection/FormSection.tsx b/app/react/components/form-components/FormSection/FormSection.tsx
index 319ecdfa9..ec09cdcea 100644
--- a/app/react/components/form-components/FormSection/FormSection.tsx
+++ b/app/react/components/form-components/FormSection/FormSection.tsx
@@ -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) {
const [isExpanded, setIsExpanded] = useState(!defaultFolded);
@@ -26,6 +28,7 @@ export function FormSection({
{isFoldable && (
) {
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({
{children}
diff --git a/app/react/components/form-components/InputGroup/InputGroupAddon.tsx b/app/react/components/form-components/InputGroup/InputGroupAddon.tsx
index 4ba446c7e..ca2d131db 100644
--- a/app/react/components/form-components/InputGroup/InputGroupAddon.tsx
+++ b/app/react/components/form-components/InputGroup/InputGroupAddon.tsx
@@ -6,11 +6,13 @@ import { useInputGroupContext } from './InputGroup';
type BaseProps
= {
as?: ComponentType | string;
required?: boolean;
+ className?: string;
};
export function InputGroupAddon({
children,
as = 'span',
+ className,
required,
...props
}: PropsWithChildren & TProps>) {
@@ -19,7 +21,7 @@ export function InputGroupAddon({
return (
diff --git a/app/react/kubernetes/applications/CreateView/KubeApplicationAccessPolicySelector.tsx b/app/react/kubernetes/applications/CreateView/DataAccessPolicyFormSection.tsx
similarity index 97%
rename from app/react/kubernetes/applications/CreateView/KubeApplicationAccessPolicySelector.tsx
rename to app/react/kubernetes/applications/CreateView/DataAccessPolicyFormSection.tsx
index f5b153cd5..664b4bd24 100644
--- a/app/react/kubernetes/applications/CreateView/KubeApplicationAccessPolicySelector.tsx
+++ b/app/react/kubernetes/applications/CreateView/DataAccessPolicyFormSection.tsx
@@ -11,7 +11,7 @@ interface Props {
onChange(value: number): void;
}
-export function KubeApplicationAccessPolicySelector({
+export function DataAccessPolicyFormSection({
isEdit,
persistedFoldersUseExistingVolumes,
value,
diff --git a/app/react/kubernetes/applications/application.queries.ts b/app/react/kubernetes/applications/application.queries.ts
index 558194418..f64354acd 100644
--- a/app/react/kubernetes/applications/application.queries.ts
+++ b/app/react/kubernetes/applications/application.queries.ts
@@ -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[]
) {
diff --git a/app/react/kubernetes/applications/components/PersistedFoldersFormSection/PersistedFolderItem.tsx b/app/react/kubernetes/applications/components/PersistedFoldersFormSection/PersistedFolderItem.tsx
new file mode 100644
index 000000000..5b27ca999
--- /dev/null
+++ b/app/react/kubernetes/applications/components/PersistedFoldersFormSection/PersistedFolderItem.tsx
@@ -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;
+ storageClasses: StorageClass[];
+ index: number;
+ PVCOptions: Option[];
+ 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 (
+
+
+
+ Path in container
+ 1
+ }
+ value={item.containerPath}
+ onChange={(e) =>
+ onChange({
+ ...item,
+ containerPath: e.target.value,
+ })
+ }
+ data-cy={`k8sAppCreate-containerPathInput_${index}`}
+ />
+
+ {formikError?.containerPath && (
+ {formikError?.containerPath}
+ )}
+
+ {isToggleVolumeTypeVisible() && (
+
+ 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 && (
+ <>
+
+
+
+ Requested size
+
+ 1
+ }
+ value={item.size}
+ onChange={(e) =>
+ onChange({
+ ...item,
+ size: e.target.value,
+ })
+ }
+ data-cy={`k8sAppCreate-persistentFolderSizeInput_${index}`}
+ />
+ >
+ 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}`}
+ />
+
+ {formikError?.size && {formikError?.size} }
+
+
+ Storage
+ >
+ 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}`}
+ />
+
+ >
+ )}
+ {!item.useNewVolume && (
+
+ Volume
+ >
+ 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}`}
+ />
+
+ )}
+
+ );
+
+ 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 };
+}
diff --git a/app/react/kubernetes/applications/components/PersistedFoldersFormSection/PersistedFoldersFormSection.tsx b/app/react/kubernetes/applications/components/PersistedFoldersFormSection/PersistedFoldersFormSection.tsx
new file mode 100644
index 000000000..4b8d08703
--- /dev/null
+++ b/app/react/kubernetes/applications/components/PersistedFoldersFormSection/PersistedFoldersFormSection.tsx
@@ -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;
+ 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 (
+
+ {storageClasses.length === 0 && (
+
+ No storage option is available to persist data, contact your
+ administrator to enable a storage option.
+
+ )}
+ {environmentQuery.isLoading && (
+ Loading volumes...
+ )}
+
+ 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) => (
+
+ )}
+ 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}
+ />
+
+ );
+
+ function isDeleteButtonHidden() {
+ return (
+ (isEdit &&
+ applicationValues.ApplicationType ===
+ KubernetesApplicationTypes.STATEFULSET) ||
+ applicationValues.Containers.length >= 1
+ );
+ }
+}
+
+function usePVCOptions(existingPVCs: ExistingVolume[]): Option[] {
+ 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 '';
+}
diff --git a/app/react/kubernetes/applications/components/PersistedFoldersFormSection/index.ts b/app/react/kubernetes/applications/components/PersistedFoldersFormSection/index.ts
new file mode 100644
index 000000000..40fae4778
--- /dev/null
+++ b/app/react/kubernetes/applications/components/PersistedFoldersFormSection/index.ts
@@ -0,0 +1 @@
+export { PersistedFoldersFormSection } from './PersistedFoldersFormSection';
diff --git a/app/react/kubernetes/applications/components/PersistedFoldersFormSection/persistedFoldersValidation.ts b/app/react/kubernetes/applications/components/PersistedFoldersFormSection/persistedFoldersValidation.ts
new file mode 100644
index 000000000..1dd19d808
--- /dev/null
+++ b/app/react/kubernetes/applications/components/PersistedFoldersFormSection/persistedFoldersValidation.ts
@@ -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;
+};
+
+export function persistedFoldersValidation(
+ formData?: FormData
+): SchemaOf {
+ 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 {
+ return object({
+ Name: string().required(),
+ AccessModes: array(string().required()).required(),
+ AllowVolumeExpansion: boolean().required(),
+ Provisioner: string().required(),
+ });
+}
+
+function existingVolumeValidation(): SchemaOf {
+ 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(),
+ }),
+ });
+}
diff --git a/app/react/kubernetes/applications/components/PersistedFoldersFormSection/types.ts b/app/react/kubernetes/applications/components/PersistedFoldersFormSection/types.ts
new file mode 100644
index 000000000..b0d4a0912
--- /dev/null
+++ b/app/react/kubernetes/applications/components/PersistedFoldersFormSection/types.ts
@@ -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;
+ };
+};
diff --git a/app/react/kubernetes/applications/types.ts b/app/react/kubernetes/applications/types.ts
index 53cedf5b0..8ba8d4695 100644
--- a/app/react/kubernetes/applications/types.ts
+++ b/app/react/kubernetes/applications/types.ts
@@ -11,6 +11,11 @@ import {
import { Pod, PodList } from 'kubernetes-types/core/v1';
import { RawExtension } from 'kubernetes-types/runtime';
+export type ApplicationFormValues = {
+ Containers: Array;
+ 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.
diff --git a/app/react/kubernetes/cluster/ConfigureView/ConfigureForm/ConfigureForm.tsx b/app/react/kubernetes/cluster/ConfigureView/ConfigureForm/ConfigureForm.tsx
index d3152f699..6ce089635 100644
--- a/app/react/kubernetes/cluster/ConfigureView/ConfigureForm/ConfigureForm.tsx
+++ b/app/react/kubernetes/cluster/ConfigureView/ConfigureForm/ConfigureForm.tsx
@@ -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';
diff --git a/app/react/kubernetes/cluster/ConfigureView/ConfigureForm/StorageClassDatatable.tsx b/app/react/kubernetes/cluster/ConfigureView/ConfigureForm/StorageClassDatatable.tsx
index de8c2dfa1..51c0a1745 100644
--- a/app/react/kubernetes/cluster/ConfigureView/ConfigureForm/StorageClassDatatable.tsx
+++ b/app/react/kubernetes/cluster/ConfigureView/ConfigureForm/StorageClassDatatable.tsx
@@ -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[];
diff --git a/app/react/kubernetes/cluster/ConfigureView/ConfigureForm/useStorageClassesFormValues.ts b/app/react/kubernetes/cluster/ConfigureView/ConfigureForm/useStorageClasses.ts
similarity index 80%
rename from app/react/kubernetes/cluster/ConfigureView/ConfigureForm/useStorageClassesFormValues.ts
rename to app/react/kubernetes/cluster/ConfigureView/ConfigureForm/useStorageClasses.ts
index 5df713fc7..8d8c6b935 100644
--- a/app/react/kubernetes/cluster/ConfigureView/ConfigureForm/useStorageClassesFormValues.ts
+++ b/app/react/kubernetes/cluster/ConfigureView/ConfigureForm/useStorageClasses.ts
@@ -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',
diff --git a/app/react/kubernetes/configs/ListView/ConfigMapsDatatable/ConfigMapsDatatable.tsx b/app/react/kubernetes/configs/ListView/ConfigMapsDatatable/ConfigMapsDatatable.tsx
index b518d60f8..bc241aca4 100644
--- a/app/react/kubernetes/configs/ListView/ConfigMapsDatatable/ConfigMapsDatatable.tsx
+++ b/app/react/kubernetes/configs/ListView/ConfigMapsDatatable/ConfigMapsDatatable.tsx
@@ -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(
() =>
diff --git a/app/react/kubernetes/configs/ListView/SecretsDatatable/SecretsDatatable.tsx b/app/react/kubernetes/configs/ListView/SecretsDatatable/SecretsDatatable.tsx
index ac7dc04f6..4ea8c6e0b 100644
--- a/app/react/kubernetes/configs/ListView/SecretsDatatable/SecretsDatatable.tsx
+++ b/app/react/kubernetes/configs/ListView/SecretsDatatable/SecretsDatatable.tsx
@@ -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(
() =>
diff --git a/app/react/kubernetes/dashboard/DashboardView.tsx b/app/react/kubernetes/dashboard/DashboardView.tsx
index a64154067..89c2a4d14 100644
--- a/app/react/kubernetes/dashboard/DashboardView.tsx
+++ b/app/react/kubernetes/dashboard/DashboardView.tsx
@@ -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
);
diff --git a/app/react/kubernetes/volumes/.keep b/app/react/kubernetes/volumes/.keep
deleted file mode 100644
index e69de29bb..000000000
diff --git a/app/react/kubernetes/volumes/queries.ts b/app/react/kubernetes/volumes/queries.ts
deleted file mode 100644
index 64ff0acee..000000000
--- a/app/react/kubernetes/volumes/queries.ts
+++ /dev/null
@@ -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,
- }
- );
-}
diff --git a/app/react/kubernetes/volumes/service.ts b/app/react/kubernetes/volumes/usePVCsQuery.ts
similarity index 53%
rename from app/react/kubernetes/volumes/service.ts
rename to app/react/kubernetes/volumes/usePVCsQuery.ts
index 8bd181426..876b68bda 100644
--- a/app/react/kubernetes/volumes/service.ts
+++ b/app/react/kubernetes/volumes/usePVCsQuery.ts
@@ -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