From 515b02813b43497e2c4b3c6edb655e5ae48388e5 Mon Sep 17 00:00:00 2001 From: Ali <83188384+testA113@users.noreply.github.com> Date: Tue, 5 Sep 2023 18:06:36 +0200 Subject: [PATCH] feat(k8sconfigure): migrate configure to react [EE-5524] (#10218) --- app/angulartics.matomo/analytics-services.ts | 18 +- app/kubernetes/__module.js | 4 +- app/kubernetes/react/components/index.ts | 3 +- app/kubernetes/react/views/index.ts | 5 + app/kubernetes/views/configure/configure.html | 347 --------------- .../views/configure/configureController.js | 397 ----------------- .../create/createResourcePool.html | 1 + .../create/createResourcePoolController.js | 3 +- .../resource-pools/edit/resourcePool.html | 1 + .../edit/resourcePoolController.js | 3 +- .../react/components/switch-field.ts | 1 + app/react/components/Alert/Alert.tsx | 29 +- .../form-components/FormActions.tsx | 3 + .../SwitchField/SwitchField.tsx | 7 +- .../edge-devices/WaitingRoomView/queries.ts | 7 +- app/react/hooks/useAnalytics.ts | 16 + .../ConfigureForm/ConfigureForm.tsx | 412 ++++++++++++++++++ .../ConfigureForm/EnableMetricsInput.tsx | 101 +++++ .../ConfigureView/ConfigureForm/RBACAlert.tsx | 39 ++ .../StorageAccessModeSelector.tsx | 4 +- .../ConfigureForm/StorageClassDatatable.tsx | 104 +++++ .../handleSubmitConfigureCluster.ts | 145 ++++++ .../ConfigureView/ConfigureForm/index.ts | 1 + .../ConfigureView/ConfigureForm/types.ts | 28 ++ .../useConfigureClusterMutation.ts | 72 +++ .../useStorageClassesFormValues.ts | 111 +++++ .../ConfigureView/ConfigureForm/validation.ts | 62 +++ .../cluster/ConfigureView/ConfigureView.tsx | 39 ++ .../kubernetes/cluster/ConfigureView/index.ts | 1 + .../kubernetes/cluster/getIsRBACEnabled.ts | 14 + .../IngressClassDatatable.tsx | 46 +- .../columns/availability.tsx | 6 +- .../IngressClassDatatable/columns/helper.ts | 5 +- .../IngressClassDatatable/columns/name.tsx | 4 +- .../IngressClassDatatable/types.ts | 21 + .../useIngressControllerClassMap.ts} | 45 +- .../kubernetes/cluster/ingressClass/types.ts | 10 +- .../useIngressControllerClassMap.ts | 94 ++++ .../queries/useGetMetricsMutation.ts | 8 + .../TimeWindowPicker/TimePickerInput.tsx | 109 +++++ .../TimeWindowPicker/TimeWindowPicker.tsx | 99 +++++ .../TimeWindowPickerInputGroup.tsx | 130 ++++++ .../common/TimeWindowPicker/index.ts | 1 + .../common/TimeWindowPicker/utils.ts | 36 ++ .../environments/queries/query-keys.ts | 6 +- .../queries/useAgentVersionsList.ts | 4 +- .../environments/queries/useEnvironment.ts | 4 +- .../queries/useEnvironmentList.ts | 4 +- .../queries/useEnvironmentRegistries.ts | 4 +- .../queries/useUpdateEnvironmentMutation.ts | 18 +- .../useUpdateEnvironmentsRelationsMutation.ts | 4 +- app/react/portainer/environments/types.ts | 2 +- .../EndpointTypeView.tsx | 2 +- .../EnvironmentsCreationView.tsx | 2 +- .../environments/wizard/HomeView/HomeView.tsx | 2 +- .../KubectlShell/KubectlShellButton.tsx | 2 +- .../UpgradeBEBanner/GetLicenseDialog.tsx | 2 +- .../UpgradeBEBanner/UpgradeBEBanner.tsx | 2 +- .../UpgradeBEBanner/UploadLicenseDialog.tsx | 2 +- 59 files changed, 1819 insertions(+), 833 deletions(-) delete mode 100644 app/kubernetes/views/configure/configure.html delete mode 100644 app/kubernetes/views/configure/configureController.js create mode 100644 app/react/hooks/useAnalytics.ts create mode 100644 app/react/kubernetes/cluster/ConfigureView/ConfigureForm/ConfigureForm.tsx create mode 100644 app/react/kubernetes/cluster/ConfigureView/ConfigureForm/EnableMetricsInput.tsx create mode 100644 app/react/kubernetes/cluster/ConfigureView/ConfigureForm/RBACAlert.tsx rename app/react/kubernetes/cluster/ConfigureView/{ => ConfigureForm}/StorageAccessModeSelector.tsx (89%) create mode 100644 app/react/kubernetes/cluster/ConfigureView/ConfigureForm/StorageClassDatatable.tsx create mode 100644 app/react/kubernetes/cluster/ConfigureView/ConfigureForm/handleSubmitConfigureCluster.ts create mode 100644 app/react/kubernetes/cluster/ConfigureView/ConfigureForm/index.ts create mode 100644 app/react/kubernetes/cluster/ConfigureView/ConfigureForm/types.ts create mode 100644 app/react/kubernetes/cluster/ConfigureView/ConfigureForm/useConfigureClusterMutation.ts create mode 100644 app/react/kubernetes/cluster/ConfigureView/ConfigureForm/useStorageClassesFormValues.ts create mode 100644 app/react/kubernetes/cluster/ConfigureView/ConfigureForm/validation.ts create mode 100644 app/react/kubernetes/cluster/ConfigureView/ConfigureView.tsx create mode 100644 app/react/kubernetes/cluster/ConfigureView/index.ts rename app/react/kubernetes/cluster/ingressClass/{utils/index.ts => IngressClassDatatable/useIngressControllerClassMap.ts} (61%) create mode 100644 app/react/kubernetes/cluster/ingressClass/useIngressControllerClassMap.ts create mode 100644 app/react/kubernetes/queries/useGetMetricsMutation.ts create mode 100644 app/react/portainer/environments/common/TimeWindowPicker/TimePickerInput.tsx create mode 100644 app/react/portainer/environments/common/TimeWindowPicker/TimeWindowPicker.tsx create mode 100644 app/react/portainer/environments/common/TimeWindowPicker/TimeWindowPickerInputGroup.tsx create mode 100644 app/react/portainer/environments/common/TimeWindowPicker/index.ts create mode 100644 app/react/portainer/environments/common/TimeWindowPicker/utils.ts diff --git a/app/angulartics.matomo/analytics-services.ts b/app/angulartics.matomo/analytics-services.ts index 94998bf8d..357703600 100644 --- a/app/angulartics.matomo/analytics-services.ts +++ b/app/angulartics.matomo/analytics-services.ts @@ -1,7 +1,5 @@ import _ from 'lodash'; -import { usePublicSettings } from '@/react/portainer/settings/queries'; - const categories = [ 'docker', 'kubernetes', @@ -18,7 +16,7 @@ enum DimensionConfig { PortainerEndpointUserRole, } -interface TrackEventProps { +export interface TrackEventProps { category: Category; metadata?: Record; value?: string | number; @@ -63,20 +61,6 @@ export function push( } } -export function useAnalytics() { - const telemetryQuery = usePublicSettings({ - select: (settings) => settings.EnableTelemetry, - }); - - return { trackEvent: handleTrackEvent }; - - function handleTrackEvent(...args: Parameters) { - if (telemetryQuery.data) { - trackEvent(...args); - } - } -} - export function trackEvent(action: string, properties: TrackEventProps) { /** * @description Logs an event with an event category (Videos, Music, Games...), an event diff --git a/app/kubernetes/__module.js b/app/kubernetes/__module.js index c80cbf1fb..87df5e54d 100644 --- a/app/kubernetes/__module.js +++ b/app/kubernetes/__module.js @@ -420,9 +420,7 @@ angular.module('portainer.kubernetes', ['portainer.app', registriesModule, custo url: '/configure', views: { 'content@': { - templateUrl: './views/configure/configure.html', - controller: 'KubernetesConfigureController', - controllerAs: 'ctrl', + component: 'kubernetesConfigureView', }, }, }; diff --git a/app/kubernetes/react/components/index.ts b/app/kubernetes/react/components/index.ts index 41056bb2b..9cf2af1fd 100644 --- a/app/kubernetes/react/components/index.ts +++ b/app/kubernetes/react/components/index.ts @@ -3,7 +3,7 @@ import angular from 'angular'; import { r2a } from '@/react-tools/react2angular'; import { IngressClassDatatable } from '@/react/kubernetes/cluster/ingressClass/IngressClassDatatable'; import { NamespacesSelector } from '@/react/kubernetes/cluster/RegistryAccessView/NamespacesSelector'; -import { StorageAccessModeSelector } from '@/react/kubernetes/cluster/ConfigureView/StorageAccessModeSelector'; +import { StorageAccessModeSelector } from '@/react/kubernetes/cluster/ConfigureView/ConfigureForm/StorageAccessModeSelector'; import { NamespaceAccessUsersSelector } from '@/react/kubernetes/namespaces/AccessView/NamespaceAccessUsersSelector'; import { CreateNamespaceRegistriesSelector } from '@/react/kubernetes/namespaces/CreateView/CreateNamespaceRegistriesSelector'; import { KubeApplicationAccessPolicySelector } from '@/react/kubernetes/applications/CreateView/KubeApplicationAccessPolicySelector'; @@ -30,6 +30,7 @@ export const ngModule = angular 'onChangeControllers', 'description', 'ingressControllers', + 'initialIngressControllers', 'allowNoneIngressClass', 'isLoading', 'noIngressControllerLabel', diff --git a/app/kubernetes/react/views/index.ts b/app/kubernetes/react/views/index.ts index 38323dc5e..3d6633286 100644 --- a/app/kubernetes/react/views/index.ts +++ b/app/kubernetes/react/views/index.ts @@ -11,6 +11,7 @@ import { ServicesView } from '@/react/kubernetes/services/ServicesView'; import { ConsoleView } from '@/react/kubernetes/applications/ConsoleView'; import { ConfigmapsAndSecretsView } from '@/react/kubernetes/configs/ListView/ConfigmapsAndSecretsView'; import { ApplicationDetailsView } from '@/react/kubernetes/applications/DetailsView/ApplicationDetailsView'; +import { ConfigureView } from '@/react/kubernetes/cluster/ConfigureView'; export const viewsModule = angular .module('portainer.kubernetes.react.views', []) @@ -43,6 +44,10 @@ export const viewsModule = angular [] ) ) + .component( + 'kubernetesConfigureView', + r2a(withUIRouter(withReactQuery(withCurrentUser(ConfigureView))), []) + ) .component( 'kubernetesDashboardView', r2a(withUIRouter(withReactQuery(withCurrentUser(DashboardView))), []) diff --git a/app/kubernetes/views/configure/configure.html b/app/kubernetes/views/configure/configure.html deleted file mode 100644 index 2b5a5c21b..000000000 --- a/app/kubernetes/views/configure/configure.html +++ /dev/null @@ -1,347 +0,0 @@ - - - - - -
-
-
- - -
-
Networking - Services
- -
-
-

Enabling the load balancer feature will allow users to expose application they deploy over an external IP address assigned by cloud provider.

-
-
-
Ensure that your cloud provider allows you to create load balancers if you want to use this feature. Might incur costs.
-
-
- -
- - -
-
- -
Networking - Ingresses
- - - -
-
- - -
-
-
-
- - -
-
-
-
- - -
-
- -
-
-
You may set up ingress defaults (hostnames and annotations) via Create/Edit ingress. Users may then select them via the hostname dropdown in Create/Edit - application.
-
- - -
Change Window Settings
- -
-
- - -
-
- - - - -
Security
- -
-
- -
-
-

Your cluster does not have Kubernetes role-based access control (RBAC) enabled.

-

This means you can't use Portainer RBAC functionality to regulate access to environment resources based on user roles.

-

- To enable RBAC, start the API server with the --authorization-mode flag set to a - comma-separated list that includes RBAC, for example:  - kube-apiserver --authorization-mode=Example1,RBAC,Example2. -

-
-
- -
- - By default, all the users have access to the default namespace. Enable this option to set accesses on the default namespace. - -
- -
-
- - -
-
- - -
Resources and Metrics
- -
-
-

- By ENABLING resource over-commit, you are able to assign more resources to namespaces than is physically available in the cluster. This may lead to unexpected - deployment failures if there is insufficient resource to service demand. -

-
-
-
By DISABLING resource over-commit (highly recommended), you are only able to assign resources to namespaces that are less (in aggregate) than the cluster total - minus any system resource reservation.
-
-
- -
- -
-
- -
-
-

Enabling this feature will allow users to use specific features like autoscaling and to see container and node resource usage.

-
-
-
Ensure that - metrics server or - prometheus is running inside your cluster.
-
-
-
-
-
- - -
-
- Checking metrics API... -
-
- Successfully reached metrics API -
-
- Unable to reach metrics API, make sure metrics server is properly deployed inside that cluster. -
-
- -
Available storage options
- -
-
- - Unable to detect any storage class available to persist data. Users won't be able to persist application data inside this cluster. -
-
- -
- -

- Select which storage options will be available for use when deploying applications. Have a look at your storage driver documentation to figure out which access - policy to configure and if the volume expansion capability is supported. -

-

- You can find more information about access modes - in the official Kubernetes documentation. -

-
-
- -
-
- - - - - - - - - - - - - -
StorageShared access policyVolume expansion
-
- - {{ class.Name }} -
-
- - -
- -
-
-
-
- - - Shared access policy configuration required - -
-
- -
Actions
- -
-
- -
-
-
-
-
-
-
-
diff --git a/app/kubernetes/views/configure/configureController.js b/app/kubernetes/views/configure/configureController.js deleted file mode 100644 index ae2d1ec7e..000000000 --- a/app/kubernetes/views/configure/configureController.js +++ /dev/null @@ -1,397 +0,0 @@ -import _ from 'lodash-es'; -import angular from 'angular'; -import { KubernetesStorageClass, KubernetesStorageClassAccessPolicies } from 'Kubernetes/models/storage-class/models'; -import { KubernetesFormValidationReferences } from 'Kubernetes/models/application/formValues'; -import { KubernetesIngressClassTypes } from 'Kubernetes/ingress/constants'; -import KubernetesNamespaceHelper from 'Kubernetes/helpers/namespaceHelper'; -import { FeatureId } from '@/react/portainer/feature-flags/enums'; - -import { getIngressControllerClassMap, updateIngressControllerClassMap } from '@/react/kubernetes/cluster/ingressClass/utils'; -import { buildConfirmButton } from '@@/modals/utils'; -import { confirm } from '@@/modals/confirm'; -import { getIsRBACEnabled } from '@/react/kubernetes/cluster/getIsRBACEnabled'; -import { ModalType } from '@@/modals/Modal/types'; -import { getMetricsForAllNodes } from '@/react/kubernetes/services/service.ts'; - -class KubernetesConfigureController { - /* #region CONSTRUCTOR */ - - /* @ngInject */ - constructor($async, $state, $scope, Notifications, KubernetesStorageService, EndpointService, EndpointProvider, KubernetesResourcePoolService, KubernetesIngressService) { - this.$async = $async; - this.$state = $state; - this.$scope = $scope; - this.Notifications = Notifications; - this.KubernetesStorageService = KubernetesStorageService; - this.EndpointService = EndpointService; - this.EndpointProvider = EndpointProvider; - this.KubernetesResourcePoolService = KubernetesResourcePoolService; - this.KubernetesIngressService = KubernetesIngressService; - - this.IngressClassTypes = KubernetesIngressClassTypes; - - this.onInit = this.onInit.bind(this); - this.configureAsync = this.configureAsync.bind(this); - this.areControllersChanged = this.areControllersChanged.bind(this); - this.areFormValuesChanged = this.areFormValuesChanged.bind(this); - this.areStorageClassesChanged = this.areStorageClassesChanged.bind(this); - this.onBeforeOnload = this.onBeforeOnload.bind(this); - this.limitedFeature = FeatureId.K8S_SETUP_DEFAULT; - this.limitedFeatureAutoWindow = FeatureId.HIDE_AUTO_UPDATE_WINDOW; - this.limitedFeatureIngressDeploy = FeatureId.K8S_ADM_ONLY_USR_INGRESS_DEPLY; - this.onToggleAutoUpdate = this.onToggleAutoUpdate.bind(this); - this.onChangeControllers = this.onChangeControllers.bind(this); - this.onChangeEnableResourceOverCommit = this.onChangeEnableResourceOverCommit.bind(this); - this.onToggleIngressAvailabilityPerNamespace = this.onToggleIngressAvailabilityPerNamespace.bind(this); - this.onToggleAllowNoneIngressClass = this.onToggleAllowNoneIngressClass.bind(this); - this.onChangeStorageClassAccessMode = this.onChangeStorageClassAccessMode.bind(this); - this.onToggleRestrictNs = this.onToggleRestrictNs.bind(this); - } - /* #endregion */ - - /* #region STORAGE CLASSES UI MANAGEMENT */ - storageClassAvailable() { - return this.StorageClasses && this.StorageClasses.length > 0; - } - - hasValidStorageConfiguration() { - let valid = true; - _.forEach(this.StorageClasses, (item) => { - if (item.selected && item.AccessModes.length === 0) { - valid = false; - } - }); - return valid; - } - /* #endregion */ - - /* #region INGRESS CLASSES UI MANAGEMENT */ - onChangeControllers(controllerClassMap) { - this.ingressControllers = controllerClassMap; - } - - hasTraefikIngress() { - return _.find(this.formValues.IngressClasses, { Type: this.IngressClassTypes.TRAEFIK }); - } - - toggleAdvancedIngSettings($event) { - $event.stopPropagation(); - $event.preventDefault(); - this.$scope.$evalAsync(() => { - this.state.isIngToggleSectionExpanded = !this.state.isIngToggleSectionExpanded; - }); - } - - onToggleAllowNoneIngressClass() { - this.$scope.$evalAsync(() => { - this.formValues.AllowNoneIngressClass = !this.formValues.AllowNoneIngressClass; - }); - } - - onToggleIngressAvailabilityPerNamespace() { - this.$scope.$evalAsync(() => { - this.formValues.IngressAvailabilityPerNamespace = !this.formValues.IngressAvailabilityPerNamespace; - }); - } - /* #endregion */ - - /* #region RESOURCES AND METRICS */ - - onChangeEnableResourceOverCommit(enabled) { - this.$scope.$evalAsync(() => { - this.formValues.EnableResourceOverCommit = enabled; - if (enabled) { - this.formValues.ResourceOverCommitPercentage = 20; - } - }); - } - - /* #endregion */ - - /* #region CONFIGURE */ - assignFormValuesToEndpoint(endpoint, storageClasses, ingressClasses) { - endpoint.Kubernetes.Configuration.StorageClasses = storageClasses; - endpoint.Kubernetes.Configuration.UseLoadBalancer = this.formValues.UseLoadBalancer; - endpoint.Kubernetes.Configuration.UseServerMetrics = this.formValues.UseServerMetrics; - endpoint.Kubernetes.Configuration.EnableResourceOverCommit = this.formValues.EnableResourceOverCommit; - endpoint.Kubernetes.Configuration.ResourceOverCommitPercentage = this.formValues.ResourceOverCommitPercentage; - endpoint.Kubernetes.Configuration.IngressClasses = ingressClasses; - endpoint.Kubernetes.Configuration.RestrictDefaultNamespace = this.formValues.RestrictDefaultNamespace; - endpoint.Kubernetes.Configuration.IngressAvailabilityPerNamespace = this.formValues.IngressAvailabilityPerNamespace; - endpoint.Kubernetes.Configuration.AllowNoneIngressClass = this.formValues.AllowNoneIngressClass; - endpoint.ChangeWindow = this.state.autoUpdateSettings; - } - - transformFormValues() { - const storageClasses = _.map(this.StorageClasses, (item) => { - if (item.selected) { - const res = new KubernetesStorageClass(); - res.Name = item.Name; - res.AccessModes = _.map(item.AccessModes, 'Name'); - res.Provisioner = item.Provisioner; - res.AllowVolumeExpansion = item.AllowVolumeExpansion; - return res; - } - }); - _.pull(storageClasses, undefined); - - const ingressClasses = _.without( - _.map(this.formValues.IngressClasses, (ic) => (ic.NeedsDeletion ? undefined : ic)), - undefined - ); - _.pull(ingressClasses, undefined); - - return [storageClasses, ingressClasses]; - } - - async removeIngressesAcrossNamespaces() { - const ingressesToDel = _.filter(this.formValues.IngressClasses, { NeedsDeletion: true }); - if (!ingressesToDel.length) { - return; - } - const promises = []; - const oldEndpoint = this.EndpointProvider.currentEndpoint(); - this.EndpointProvider.setCurrentEndpoint(this.endpoint); - - try { - const allResourcePools = await this.KubernetesResourcePoolService.get(); - const resourcePools = _.filter( - allResourcePools, - (resourcePool) => - !KubernetesNamespaceHelper.isSystemNamespace(resourcePool.Namespace.Name) && - !KubernetesNamespaceHelper.isDefaultNamespace(resourcePool.Namespace.Name) && - resourcePool.Namespace.Status === 'Active' - ); - - ingressesToDel.forEach((ingress) => { - resourcePools.forEach((resourcePool) => { - promises.push(this.KubernetesIngressService.delete(resourcePool.Namespace.Name, ingress.Name)); - }); - }); - } finally { - this.EndpointProvider.setCurrentEndpoint(oldEndpoint); - } - - const responses = await Promise.allSettled(promises); - responses.forEach((respons) => { - if (respons.status == 'rejected' && respons.reason.err.status != 404) { - throw respons.reason; - } - }); - } - - enableMetricsServer() { - return this.$async(async () => { - if (this.formValues.UseServerMetrics) { - this.state.metrics.userClick = true; - this.state.metrics.pending = true; - try { - await getMetricsForAllNodes(this.endpoint.Id); - this.state.metrics.isServerRunning = true; - this.state.metrics.pending = false; - this.state.metrics.userClick = true; - this.formValues.UseServerMetrics = true; - } catch (_) { - this.state.metrics.isServerRunning = false; - this.state.metrics.pending = false; - this.formValues.UseServerMetrics = false; - } - } else { - this.state.metrics.userClick = false; - this.formValues.UseServerMetrics = false; - } - }); - } - - async configureAsync() { - try { - this.state.actionInProgress = true; - const [storageClasses, ingressClasses] = this.transformFormValues(); - - await this.removeIngressesAcrossNamespaces(); - - this.assignFormValuesToEndpoint(this.endpoint, storageClasses, ingressClasses); - await this.EndpointService.updateEndpoint(this.endpoint.Id, this.endpoint); - // updateIngressControllerClassMap must be done after updateEndpoint, as a hacky workaround. A better solution: saving ingresscontrollers somewhere else, is being discussed - await updateIngressControllerClassMap(this.state.endpointId, this.ingressControllers || []); - this.state.isSaving = true; - const storagePromises = _.map(storageClasses, (storageClass) => { - const oldStorageClass = _.find(this.oldStorageClasses, { Name: storageClass.Name }); - if (oldStorageClass) { - return this.KubernetesStorageService.patch(this.state.endpointId, oldStorageClass, storageClass); - } - }); - await Promise.all(storagePromises); - this.$state.reload(); - this.Notifications.success('Success', 'Configuration successfully applied'); - } catch (err) { - this.Notifications.error('Failure', err, 'Unable to apply configuration'); - } finally { - this.state.actionInProgress = false; - } - } - - configure() { - return this.$async(this.configureAsync); - } - /* #endregion */ - - restrictDefaultToggledOn() { - return this.formValues.RestrictDefaultNamespace && !this.oldFormValues.RestrictDefaultNamespace; - } - - onToggleAutoUpdate(value) { - return this.$scope.$evalAsync(() => { - this.state.autoUpdateSettings.Enabled = value; - }); - } - - onChangeStorageClassAccessMode(storageClassName, accessModes) { - return this.$scope.$evalAsync(() => { - const storageClass = this.StorageClasses.find((item) => item.Name === storageClassName); - - if (!storageClass) { - throw new Error('Storage class not found'); - } - - storageClass.AccessModes = accessModes; - }); - } - - onToggleRestrictNs() { - this.$scope.$evalAsync(() => { - this.formValues.RestrictDefaultNamespace = !this.formValues.RestrictDefaultNamespace; - }); - } - - /* #region ON INIT */ - async onInit() { - this.state = { - actionInProgress: false, - displayConfigureClassPanel: {}, - viewReady: false, - isIngToggleSectionExpanded: false, - endpointId: this.$state.params.endpointId, - duplicates: { - ingressClasses: new KubernetesFormValidationReferences(), - }, - metrics: { - pending: false, - isServerRunning: false, - userClick: false, - }, - timeZone: '', - isSaving: false, - }; - - this.formValues = { - UseLoadBalancer: false, - UseServerMetrics: false, - EnableResourceOverCommit: true, - ResourceOverCommitPercentage: 20, - IngressClasses: [], - RestrictDefaultNamespace: false, - enableAutoUpdateTimeWindow: false, - IngressAvailabilityPerNamespace: false, - }; - - // default to true if error is thrown - this.isRBACEnabled = true; - - this.isIngressControllersLoading = true; - try { - this.availableAccessModes = new KubernetesStorageClassAccessPolicies(); - - [this.StorageClasses, this.endpoint, this.isRBACEnabled] = await Promise.all([ - this.KubernetesStorageService.get(this.state.endpointId), - this.EndpointService.endpoint(this.state.endpointId), - getIsRBACEnabled(this.state.endpointId), - ]); - - this.ingressControllers = await getIngressControllerClassMap({ environmentId: this.state.endpointId }); - this.originalIngressControllers = structuredClone(this.ingressControllers) || []; - - this.state.autoUpdateSettings = this.endpoint.ChangeWindow; - - _.forEach(this.StorageClasses, (item) => { - const storage = _.find(this.endpoint.Kubernetes.Configuration.StorageClasses, (sc) => sc.Name === item.Name); - if (storage) { - item.selected = true; - item.AccessModes = storage.AccessModes.map((name) => this.availableAccessModes.find((accessMode) => accessMode.Name === name)); - } else if (this.availableAccessModes.length) { - // set a default access mode if the storage class is not enabled and there are available access modes - item.AccessModes = [this.availableAccessModes[0]]; - } - }); - - this.formValues.UseLoadBalancer = this.endpoint.Kubernetes.Configuration.UseLoadBalancer; - this.formValues.UseServerMetrics = this.endpoint.Kubernetes.Configuration.UseServerMetrics; - this.formValues.EnableResourceOverCommit = this.endpoint.Kubernetes.Configuration.EnableResourceOverCommit; - this.formValues.ResourceOverCommitPercentage = this.endpoint.Kubernetes.Configuration.ResourceOverCommitPercentage; - this.formValues.RestrictDefaultNamespace = this.endpoint.Kubernetes.Configuration.RestrictDefaultNamespace; - this.formValues.IngressClasses = _.map(this.endpoint.Kubernetes.Configuration.IngressClasses, (ic) => { - ic.IsNew = false; - ic.NeedsDeletion = false; - return ic; - }); - this.formValues.IngressAvailabilityPerNamespace = this.endpoint.Kubernetes.Configuration.IngressAvailabilityPerNamespace; - this.formValues.AllowNoneIngressClass = this.endpoint.Kubernetes.Configuration.AllowNoneIngressClass; - - this.oldStorageClasses = angular.copy(this.StorageClasses); - this.oldFormValues = angular.copy(this.formValues); - } catch (err) { - this.Notifications.error('Failure', err, 'Unable to retrieve environment configuration'); - } finally { - this.state.viewReady = true; - this.isIngressControllersLoading = false; - } - - window.addEventListener('beforeunload', this.onBeforeOnload); - } - - $onInit() { - return this.$async(this.onInit); - } - /* #endregion */ - - $onDestroy() { - window.removeEventListener('beforeunload', this.onBeforeOnload); - } - - areControllersChanged() { - return !_.isEqual(this.ingressControllers, this.originalIngressControllers); - } - - areFormValuesChanged() { - return !_.isEqual(this.formValues, this.oldFormValues); - } - - areStorageClassesChanged() { - // angular is pesky and modifies this.StorageClasses (adds $$hashkey to each item) - // angular.toJson removes this to make the comparison work - const storageClassesWithoutHashKey = angular.toJson(this.StorageClasses); - const oldStorageClassesWithoutHashKey = angular.toJson(this.oldStorageClasses); - return !_.isEqual(storageClassesWithoutHashKey, oldStorageClassesWithoutHashKey); - } - - onBeforeOnload(event) { - if (!this.state.isSaving && (this.areControllersChanged() || this.areFormValuesChanged() || this.areStorageClassesChanged())) { - event.preventDefault(); - event.returnValue = ''; - } - } - - uiCanExit() { - if (!this.state.isSaving && (this.areControllersChanged() || this.areFormValuesChanged() || this.areStorageClassesChanged()) && !this.isIngressControllersLoading) { - return confirm({ - title: 'Are you sure?', - message: 'You currently have unsaved changes in the cluster setup view. Are you sure you want to leave?', - modalType: ModalType.Warn, - confirmButton: buildConfirmButton('Yes', 'danger'), - }); - } - } -} - -export default KubernetesConfigureController; -angular.module('portainer.kubernetes').controller('KubernetesConfigureController', KubernetesConfigureController); diff --git a/app/kubernetes/views/resource-pools/create/createResourcePool.html b/app/kubernetes/views/resource-pools/create/createResourcePool.html index b3398ebcb..7c78743ca 100644 --- a/app/kubernetes/views/resource-pools/create/createResourcePool.html +++ b/app/kubernetes/views/resource-pools/create/createResourcePool.html @@ -190,6 +190,7 @@ ng-if="$ctrl.state.ingressAvailabilityPerNamespace" on-change-controllers="($ctrl.onChangeIngressControllerAvailability)" ingress-controllers="$ctrl.ingressControllers" + initial-ingress-controllers="$ctrl.initialIngressControllers" description="'Enable the ingress controllers that users can select when publishing applications in this namespace.'" no-ingress-controller-label="'No ingress controllers found in the cluster. Go to the cluster setup view to configure and allow the use of ingress controllers in the cluster.'" view="'namespace'" diff --git a/app/kubernetes/views/resource-pools/create/createResourcePoolController.js b/app/kubernetes/views/resource-pools/create/createResourcePoolController.js index 3759175c8..7d46c926f 100644 --- a/app/kubernetes/views/resource-pools/create/createResourcePoolController.js +++ b/app/kubernetes/views/resource-pools/create/createResourcePoolController.js @@ -7,7 +7,7 @@ import { KubernetesIngressConverter } from 'Kubernetes/ingress/converter'; import { KubernetesFormValidationReferences } from 'Kubernetes/models/application/formValues'; import { KubernetesIngressClassTypes } from 'Kubernetes/ingress/constants'; import { FeatureId } from '@/react/portainer/feature-flags/enums'; -import { getIngressControllerClassMap, updateIngressControllerClassMap } from '@/react/kubernetes/cluster/ingressClass/utils'; +import { getIngressControllerClassMap, updateIngressControllerClassMap } from '@/react/kubernetes/cluster/ingressClass/useIngressControllerClassMap'; class KubernetesCreateResourcePoolController { /* #region CONSTRUCTOR */ @@ -201,6 +201,7 @@ class KubernetesCreateResourcePoolController { this.ingressControllers = []; if (this.state.ingressAvailabilityPerNamespace) { this.ingressControllers = await getIngressControllerClassMap({ environmentId: this.endpoint.Id, allowedOnly: true }); + this.initialIngressControllers = structuredClone(this.ingressControllers); } _.forEach(nodes, (item) => { diff --git a/app/kubernetes/views/resource-pools/edit/resourcePool.html b/app/kubernetes/views/resource-pools/edit/resourcePool.html index 86ab683ad..86fdae608 100644 --- a/app/kubernetes/views/resource-pools/edit/resourcePool.html +++ b/app/kubernetes/views/resource-pools/edit/resourcePool.html @@ -174,6 +174,7 @@ ng-if="ctrl.state.ingressAvailabilityPerNamespace" on-change-controllers="(ctrl.onChangeIngressControllerAvailability)" ingress-controllers="ctrl.ingressControllers" + initial-ingress-controllers="$ctrl.initialIngressControllers" description="'Enable the ingress controllers that users can select when publishing applications in this namespace.'" no-ingress-controller-label="'No ingress controllers found in the cluster. Go to the cluster setup view to configure and allow the use of ingress controllers in the cluster.'" view="'namespace'" diff --git a/app/kubernetes/views/resource-pools/edit/resourcePoolController.js b/app/kubernetes/views/resource-pools/edit/resourcePoolController.js index 2a27cf788..76762e84d 100644 --- a/app/kubernetes/views/resource-pools/edit/resourcePoolController.js +++ b/app/kubernetes/views/resource-pools/edit/resourcePoolController.js @@ -13,7 +13,7 @@ import { KubernetesIngressClassTypes } from 'Kubernetes/ingress/constants'; import KubernetesResourceQuotaConverter from 'Kubernetes/converters/resourceQuota'; import KubernetesNamespaceHelper from 'Kubernetes/helpers/namespaceHelper'; import { FeatureId } from '@/react/portainer/feature-flags/enums'; -import { updateIngressControllerClassMap, getIngressControllerClassMap } from '@/react/kubernetes/cluster/ingressClass/utils'; +import { updateIngressControllerClassMap, getIngressControllerClassMap } from '@/react/kubernetes/cluster/ingressClass/useIngressControllerClassMap'; import { confirmUpdate } from '@@/modals/confirm'; import { confirmUpdateNamespace } from '@/react/kubernetes/namespaces/ItemView/ConfirmUpdateNamespace'; import { getMetricsForAllPods } from '@/react/kubernetes/services/service.ts'; @@ -373,6 +373,7 @@ class KubernetesResourcePoolController { this.ingressControllers = []; if (this.state.ingressAvailabilityPerNamespace) { this.ingressControllers = await getIngressControllerClassMap({ environmentId: this.endpoint.Id, namespace: name }); + this.initialIngressControllers = structuredClone(this.ingressControllers); } this.pool = _.find(pools, { Namespace: { Name: name } }); diff --git a/app/portainer/react/components/switch-field.ts b/app/portainer/react/components/switch-field.ts index bd8743efe..8611b4974 100644 --- a/app/portainer/react/components/switch-field.ts +++ b/app/portainer/react/components/switch-field.ts @@ -16,4 +16,5 @@ export const switchField = r2a(SwitchField, [ 'featureId', 'switchClass', 'setTooltipHtmlMessage', + 'valueExplanation', ]); diff --git a/app/react/components/Alert/Alert.tsx b/app/react/components/Alert/Alert.tsx index 6cbd101a6..02c6e995e 100644 --- a/app/react/components/Alert/Alert.tsx +++ b/app/react/components/Alert/Alert.tsx @@ -43,23 +43,33 @@ const alertSettings: Record< export function Alert({ color, title, + className, children, -}: PropsWithChildren<{ color: AlertType; title?: string }>) { +}: PropsWithChildren<{ + color: AlertType; + title?: string; + className?: string; +}>) { const { container, header, body, icon } = alertSettings[color]; return ( - + {title ? ( <> {title} - {children} + + {children} + ) : ( - - {children} + + {children} )} @@ -96,7 +106,12 @@ function AlertHeader({ function AlertBody({ className, + hasTitle, children, -}: PropsWithChildren<{ className?: string }>) { - return
{children}
; +}: PropsWithChildren<{ className?: string; hasTitle: boolean }>) { + return ( +
+ {children} +
+ ); } diff --git a/app/react/components/form-components/FormActions.tsx b/app/react/components/form-components/FormActions.tsx index d857cb994..2e4081d7d 100644 --- a/app/react/components/form-components/FormActions.tsx +++ b/app/react/components/form-components/FormActions.tsx @@ -7,6 +7,7 @@ interface Props { loadingText: string; isLoading: boolean; isValid: boolean; + 'data-cy'?: string; } export function FormActions({ @@ -15,6 +16,7 @@ export function FormActions({ isLoading, children, isValid, + 'data-cy': dataCy, }: PropsWithChildren) { return (
@@ -24,6 +26,7 @@ export function FormActions({ loadingText={loadingText} isLoading={isLoading} disabled={!isValid} + data-cy={dataCy} > {submitLabel} diff --git a/app/react/components/form-components/SwitchField/SwitchField.tsx b/app/react/components/form-components/SwitchField/SwitchField.tsx index d8f9d783d..ecfb8a09a 100644 --- a/app/react/components/form-components/SwitchField/SwitchField.tsx +++ b/app/react/components/form-components/SwitchField/SwitchField.tsx @@ -1,6 +1,6 @@ import clsx from 'clsx'; -import { ComponentProps } from 'react'; import uuid from 'uuid'; +import { ComponentProps, PropsWithChildren, ReactNode } from 'react'; import { FeatureId } from '@/react/portainer/feature-flags/enums'; @@ -24,6 +24,7 @@ export interface Props { dataCy?: string; disabled?: boolean; featureId?: FeatureId; + valueExplanation?: ReactNode; } export function SwitchField({ @@ -40,7 +41,8 @@ export function SwitchField({ featureId, switchClass, setTooltipHtmlMessage, -}: Props) { + valueExplanation, +}: PropsWithChildren) { const toggleName = name ? `toggle_${name}` : ''; return ( @@ -65,6 +67,7 @@ export function SwitchField({ featureId={featureId} dataCy={dataCy} /> + {valueExplanation && {valueExplanation}}
); } diff --git a/app/react/edge/edge-devices/WaitingRoomView/queries.ts b/app/react/edge/edge-devices/WaitingRoomView/queries.ts index cd8b88678..2705ee4aa 100644 --- a/app/react/edge/edge-devices/WaitingRoomView/queries.ts +++ b/app/react/edge/edge-devices/WaitingRoomView/queries.ts @@ -12,7 +12,7 @@ import { } from '@/react-tools/react-query'; import { queryKey as nodesCountQueryKey } from '@/react/portainer/system/useNodesCount'; import { LicenseType } from '@/react/portainer/licenses/types'; -import { queryKeys } from '@/react/portainer/environments/queries/query-keys'; +import { environmentQueryKeys } from '@/react/portainer/environments/queries/query-keys'; export function useAssociateDeviceMutation() { const queryClient = useQueryClient(); @@ -22,7 +22,10 @@ export function useAssociateDeviceMutation() { promiseSequence(ids.map((id) => () => associateDevice(id))), mutationOptions( withError('Failed to associate devices'), - withInvalidate(queryClient, [queryKeys.base(), nodesCountQueryKey]) + withInvalidate(queryClient, [ + environmentQueryKeys.base(), + nodesCountQueryKey, + ]) ) ); } diff --git a/app/react/hooks/useAnalytics.ts b/app/react/hooks/useAnalytics.ts new file mode 100644 index 000000000..8dd0a9cbc --- /dev/null +++ b/app/react/hooks/useAnalytics.ts @@ -0,0 +1,16 @@ +import { trackEvent } from '@/angulartics.matomo/analytics-services'; +import { usePublicSettings } from '@/react/portainer/settings/queries'; + +export function useAnalytics() { + const telemetryQuery = usePublicSettings({ + select: (settings) => settings.EnableTelemetry, + }); + + return { trackEvent: handleTrackEvent }; + + function handleTrackEvent(...args: Parameters) { + if (telemetryQuery.data) { + trackEvent(...args); + } + } +} diff --git a/app/react/kubernetes/cluster/ConfigureView/ConfigureForm/ConfigureForm.tsx b/app/react/kubernetes/cluster/ConfigureView/ConfigureForm/ConfigureForm.tsx new file mode 100644 index 000000000..c61f730b3 --- /dev/null +++ b/app/react/kubernetes/cluster/ConfigureView/ConfigureForm/ConfigureForm.tsx @@ -0,0 +1,412 @@ +import { Formik, Form, FormikProps, FormikHelpers } from 'formik'; +import { useCallback, useEffect, useMemo } from 'react'; +import _ from 'lodash'; +import { useTransitionHook } from '@uirouter/react'; + +import { useCurrentEnvironment } from '@/react/hooks/useCurrentEnvironment'; +import { IngressClassDatatable } from '@/react/kubernetes/cluster/ingressClass/IngressClassDatatable'; +import { + Environment, + EnvironmentId, +} from '@/react/portainer/environments/types'; +import { useAnalytics } from '@/react/hooks/useAnalytics'; +import { FeatureId } from '@/react/portainer/feature-flags/enums'; + +import { FormSection } from '@@/form-components/FormSection'; +import { TextTip } from '@@/Tip/TextTip'; +import { SwitchField } from '@@/form-components/SwitchField'; +import { FormActions } from '@@/form-components/FormActions'; +import { confirm } from '@@/modals/confirm'; +import { ModalType } from '@@/modals'; +import { buildConfirmButton } from '@@/modals/utils'; + +import { useIngressControllerClassMapQuery } from '../../ingressClass/useIngressControllerClassMap'; +import { + IngressControllerClassMap, + IngressControllerClassMapRowData, +} from '../../ingressClass/types'; +import { useIsRBACEnabledQuery } from '../../getIsRBACEnabled'; + +import { useStorageClassesFormValues } from './useStorageClassesFormValues'; +import { ConfigureFormValues, StorageClassFormValues } from './types'; +import { configureValidationSchema } from './validation'; +import { RBACAlert } from './RBACAlert'; +import { EnableMetricsInput } from './EnableMetricsInput'; +import { StorageClassDatatable } from './StorageClassDatatable'; +import { useConfigureClusterMutation } from './useConfigureClusterMutation'; +import { handleSubmitConfigureCluster } from './handleSubmitConfigureCluster'; + +export function ConfigureForm() { + const { trackEvent } = useAnalytics(); + const configureClusterMutation = useConfigureClusterMutation(); + // get the initial values + const { data: environment } = useCurrentEnvironment(); + const { data: storageClassFormValues } = + useStorageClassesFormValues(environment); + const { data: ingressClasses, ...ingressClassesQuery } = + useIngressControllerClassMapQuery({ + environmentId: environment?.Id, + }); + const initialValues = useInitialValues( + environment, + storageClassFormValues, + ingressClasses + ); + + if (!initialValues || !environment) { + return null; + } + + return ( + + initialValues={initialValues} + onSubmit={( + values: ConfigureFormValues, + formikHelpers: FormikHelpers + ) => { + handleSubmitConfigureCluster( + values, + initialValues, + configureClusterMutation, + formikHelpers, + trackEvent, + environment + ); + }} + validationSchema={configureValidationSchema} + validateOnMount + enableReinitialize // enableReinitialize is needed to update the form values when the ingress classes data is fetched + > + {(formikProps) => ( + + )} + + ); +} + +function InnerForm({ + initialValues, + setFieldValue, + isValid, + isSubmitting, + values, + errors, + isIngressClassesLoading, + environmentId, +}: FormikProps & { + isIngressClassesLoading: boolean; + environmentId: EnvironmentId; +}) { + const { data: isRBACEnabled, ...isRBACEnabledQuery } = + useIsRBACEnabledQuery(environmentId); + + const onChangeControllers = useCallback( + (controllerClassMap: IngressControllerClassMap[]) => + setFieldValue('ingressClasses', controllerClassMap), + [setFieldValue] + ); + + // when navigating away from the page with unsaved changes, show a portainer prompt to confirm + useTransitionHook('onBefore', {}, async () => { + if (!isFormChanged(values, initialValues)) { + return true; + } + const confirmed = await confirm({ + modalType: ModalType.Warn, + title: 'Are you sure?', + message: + 'You currently have unsaved changes in the cluster setup view. Are you sure you want to leave?', + confirmButton: buildConfirmButton('Yes', 'danger'), + }); + return confirmed; + }); + + // when reloading or exiting the page with unsaved changes, show a browser prompt to confirm + useEffect(() => { + // the handler for showing the prompt + // https://developer.mozilla.org/en-US/docs/Web/API/WindowEventHandlers/onbeforeunload + function handler(event: BeforeUnloadEvent) { + event.preventDefault(); + // eslint-disable-next-line no-param-reassign + event.returnValue = ''; + } + + // if the form is changed, then set the onbeforeunload + if (isFormChanged(values, initialValues)) { + window.addEventListener('beforeunload', handler); + return () => { + window.removeEventListener('beforeunload', handler); + }; + } + return () => {}; + }, [values, initialValues]); + + return ( +
+
+ + + Enabling the load balancer feature will allow users to expose + applications they deploy over an external IP address assigned by the + cloud provider. + + + If you want to use this feature, ensure your cloud provider allows + you to create load balancers. This may incur costs. + +
+
+ + setFieldValue('useLoadBalancer', checked) + } + /> +
+
+
+ + +
+
+ + setFieldValue('allowNoneIngressClass', checked) + } + /> +
+
+
+
+ + setFieldValue('ingressAvailabilityPerNamespace', checked) + } + /> +
+
+
+
+ + setFieldValue('restrictStandardUserIngressW', checked) + } + /> +
+
+ + You may set up ingress defaults (hostnames and annotations) via + Create/Edit ingress. Users may then select them via the hostname + dropdown in Create/Edit application. + +
+ +
+
+ {}} + /> +
+
+
+ + {!isRBACEnabled && isRBACEnabledQuery.isSuccess && } + +

+ By default, all the users have access to the default namespace. + Enable this option to set accesses on the default namespace. +

+
+
+
+ + setFieldValue('restrictDefaultNamespace', checked) + } + /> +
+
+
+ + +

+ By ENABLING resource over-commit, you are able to assign more + resources to namespaces than is physically available in the + cluster. This may lead to unexpected deployment failures if there + is insufficient resource to service demand. +

+
+ +

+ By DISABLING resource over-commit (highly recommended), you are + only able to assign resources to namespaces that are less (in + aggregate) than the cluster total minus any system resource + reservation. +

+
+
+
+ { + setFieldValue('enableResourceOverCommit', checked); + // set 20% as the default resourceOverCommitPercentage value + if (!checked) { + setFieldValue('resourceOverCommitPercentage', 20); + } + }} + data-cy="kubeSetup-resourceOverCommitToggle" + /> +
+
+ +
+ + {initialValues.storageClasses.length === 0 && ( + + Unable to detect any storage class available to persist data. + Users won't be able to persist application data inside this + cluster. + + )} + {initialValues.storageClasses.length > 0 && ( + <> + +

+ Select which storage options will be available for use when + deploying applications. Have a look at your storage driver + documentation to figure out which access policy to configure + and if the volume expansion capability is supported. +

+

+ You can find more information about access modes{' '} + + in the official Kubernetes documentation + + . +

+
+ + + )} +
+ +
+
+ ); +} + +function useInitialValues( + environment?: Environment | null, + storageClassFormValues?: StorageClassFormValues[], + ingressClasses?: IngressControllerClassMapRowData[] +): ConfigureFormValues | undefined { + return useMemo(() => { + if (!environment) { + return undefined; + } + return { + storageClasses: storageClassFormValues || [], + useLoadBalancer: !!environment.Kubernetes.Configuration.UseLoadBalancer, + useServerMetrics: !!environment.Kubernetes.Configuration.UseServerMetrics, + enableResourceOverCommit: + !!environment.Kubernetes.Configuration.EnableResourceOverCommit, + resourceOverCommitPercentage: + environment.Kubernetes.Configuration.ResourceOverCommitPercentage || 20, + restrictDefaultNamespace: + !!environment.Kubernetes.Configuration.RestrictDefaultNamespace, + restrictStandardUserIngressW: + !!environment.Kubernetes.Configuration.RestrictStandardUserIngressW, + ingressAvailabilityPerNamespace: + !!environment.Kubernetes.Configuration.IngressAvailabilityPerNamespace, + allowNoneIngressClass: + !!environment.Kubernetes.Configuration.AllowNoneIngressClass, + ingressClasses: ingressClasses || [], + }; + }, [environment, ingressClasses, storageClassFormValues]); +} + +function isFormChanged( + values: ConfigureFormValues, + initialValues: ConfigureFormValues +) { + // check if the form values are different from the initial values + return !_.isEqual(values, initialValues); +} diff --git a/app/react/kubernetes/cluster/ConfigureView/ConfigureForm/EnableMetricsInput.tsx b/app/react/kubernetes/cluster/ConfigureView/ConfigureForm/EnableMetricsInput.tsx new file mode 100644 index 000000000..0a7ce475f --- /dev/null +++ b/app/react/kubernetes/cluster/ConfigureView/ConfigureForm/EnableMetricsInput.tsx @@ -0,0 +1,101 @@ +import { Field, useFormikContext } from 'formik'; +import { useState } from 'react'; +import { CheckCircle, XCircle } from 'lucide-react'; + +import { useGetMetricsMutation } from '@/react/kubernetes/queries/useGetMetricsMutation'; + +import { TextTip } from '@@/Tip/TextTip'; +import { FormControl } from '@@/form-components/FormControl'; +import { Switch } from '@@/form-components/SwitchField/Switch'; +import { InlineLoader } from '@@/InlineLoader'; + +import { ConfigureFormValues } from './types'; + +type Props = { + environmentId: number; + value: boolean; + error?: string; +}; + +export function EnableMetricsInput({ value, error, environmentId }: Props) { + const { setFieldValue } = useFormikContext(); + const [metricsFound, setMetricsFound] = useState(); + const getMetricsMutation = useGetMetricsMutation(); + return ( +
+ +

+ Enabling this feature will allow users to use specific features like + autoscaling and to see container and node resource usage. +

+

+ Ensure that  + + metrics server + +  or  + + prometheus + +  is running inside your cluster. +

+
+ + { + // if turning off, just set the value + if (!checked) { + setFieldValue('useServerMetrics', checked); + return; + } + // if turning on, see if the metrics server is available, then set the value to on if it is + getMetricsMutation.mutate(environmentId, { + onSuccess: () => { + setMetricsFound(true); + setFieldValue('useServerMetrics', checked); + }, + onError: () => { + setMetricsFound(false); + }, + }); + }} + data-cy="kubeSetup-metricsToggle" + /> + + {getMetricsMutation.isLoading && ( + Checking metrics API... + )} + {!getMetricsMutation.isLoading && ( + <> + {metricsFound === false && ( + + Unable to reach metrics API, make sure metrics server is properly + deployed inside that cluster. + + )} + {metricsFound === true && ( + + Successfully reached metrics API + + )} + + )} +
+ ); +} diff --git a/app/react/kubernetes/cluster/ConfigureView/ConfigureForm/RBACAlert.tsx b/app/react/kubernetes/cluster/ConfigureView/ConfigureForm/RBACAlert.tsx new file mode 100644 index 000000000..ba13ccc2a --- /dev/null +++ b/app/react/kubernetes/cluster/ConfigureView/ConfigureForm/RBACAlert.tsx @@ -0,0 +1,39 @@ +import { Alert } from '@@/Alert'; + +export function RBACAlert() { + return ( + +
+

+ Your cluster does not have Kubernetes role-based access control (RBAC) + enabled. +

+

This means you can't use Portainer RBAC functionality to

+

+ To enable RBAC, start the  + + API server + +  with the  + + --authorization-mode + +  flag set to a comma-separated list that includes  + + RBAC + + , for example:  + + kube-apiserver --authorization-mode=Example1,RBAC,Example2 + + . +

+
+
+ ); +} diff --git a/app/react/kubernetes/cluster/ConfigureView/StorageAccessModeSelector.tsx b/app/react/kubernetes/cluster/ConfigureView/ConfigureForm/StorageAccessModeSelector.tsx similarity index 89% rename from app/react/kubernetes/cluster/ConfigureView/StorageAccessModeSelector.tsx rename to app/react/kubernetes/cluster/ConfigureView/ConfigureForm/StorageAccessModeSelector.tsx index 7a17db48b..6f01ad877 100644 --- a/app/react/kubernetes/cluster/ConfigureView/StorageAccessModeSelector.tsx +++ b/app/react/kubernetes/cluster/ConfigureView/ConfigureForm/StorageAccessModeSelector.tsx @@ -9,7 +9,7 @@ interface Option { interface Props { value: Option[]; - onChange(storageClassName: string, value: readonly Option[]): void; + onChange(value: readonly Option[]): void; options: Option[]; inputId?: string; storageClassName: string; @@ -31,7 +31,7 @@ export function StorageAccessModeSelector({ options={options} value={value} closeMenuOnSelect={false} - onChange={(value) => onChange(storageClassName, value)} + onChange={(value) => onChange(value)} inputId={inputId} placeholder="Not configured" data-cy={`kubeSetup-storageAccessSelect${storageClassName}`} diff --git a/app/react/kubernetes/cluster/ConfigureView/ConfigureForm/StorageClassDatatable.tsx b/app/react/kubernetes/cluster/ConfigureView/ConfigureForm/StorageClassDatatable.tsx new file mode 100644 index 000000000..de8c2dfa1 --- /dev/null +++ b/app/react/kubernetes/cluster/ConfigureView/ConfigureForm/StorageClassDatatable.tsx @@ -0,0 +1,104 @@ +import { useFormikContext } from 'formik'; + +import { TextTip } from '@@/Tip/TextTip'; +import { Switch } from '@@/form-components/SwitchField/Switch'; + +import { StorageAccessModeSelector } from './StorageAccessModeSelector'; +import { ConfigureFormValues, StorageClassFormValues } from './types'; +import { availableStorageClassPolicies } from './useStorageClassesFormValues'; + +type Props = { + storageClassValues: StorageClassFormValues[]; +}; + +export function StorageClassDatatable({ storageClassValues }: Props) { + const { setFieldValue } = useFormikContext(); + return ( +
+
+ + + + + + + + {storageClassValues.map((storageClassValue, index) => ( + + + + + + ))} + +
StorageShared access policyVolume expansion
+
+ + setFieldValue( + `storageClasses.${index}.selected`, + checked + ) + } + className="mr-2 mb-0" + id={`kubeSetup-storageToggle${storageClassValue.Name}`} + name={`kubeSetup-storageToggle${storageClassValue.Name}`} + dataCy={`kubeSetup-storageToggle${storageClassValue.Name}`} + /> + {storageClassValue.Name} +
+
+ { + setFieldValue( + `storageClasses.${index}.AccessModes`, + accessModes + ); + }} + storageClassName={storageClassValue.Name} + /> + +
+ + setFieldValue( + `storageClasses.${index}.AllowVolumeExpansion`, + checked + ) + } + className="mr-2 mb-0" + dataCy={`kubeSetup-storageExpansionToggle${storageClassValue.Name}`} + id={`kubeSetup-storageExpansionToggle${storageClassValue.Name}`} + name={`kubeSetup-storageExpansionToggle${storageClassValue.Name}`} + /> +
+
+
+ {!hasValidStorageConfiguration(storageClassValues) && ( +
+ + Shared access policy configuration required. + +
+ )} +
+ ); +} + +function hasValidStorageConfiguration( + storageClassValues: StorageClassFormValues[] +) { + return storageClassValues.every( + (storageClassValue) => + // if the storage class is not selected, it's valid + !storageClassValue.selected || + // if the storage class is selected, it must have at least one access mode + storageClassValue.AccessModes.length > 0 + ); +} diff --git a/app/react/kubernetes/cluster/ConfigureView/ConfigureForm/handleSubmitConfigureCluster.ts b/app/react/kubernetes/cluster/ConfigureView/ConfigureForm/handleSubmitConfigureCluster.ts new file mode 100644 index 000000000..0a34c401a --- /dev/null +++ b/app/react/kubernetes/cluster/ConfigureView/ConfigureForm/handleSubmitConfigureCluster.ts @@ -0,0 +1,145 @@ +import { FormikHelpers } from 'formik'; +import { StorageClass } from 'kubernetes-types/storage/v1'; +import { compare } from 'fast-json-patch'; +import { UseMutationResult } from 'react-query'; + +import { notifyError, notifySuccess } from '@/portainer/services/notifications'; +import { UpdateEnvironmentPayload } from '@/react/portainer/environments/queries/useUpdateEnvironmentMutation'; +import { Environment } from '@/react/portainer/environments/types'; +import { TrackEventProps } from '@/angulartics.matomo/analytics-services'; + +import { IngressControllerClassMapRowData } from '../../ingressClass/types'; + +import { ConfigureFormValues, StorageClassFormValues } from './types'; +import { ConfigureClusterPayloads } from './useConfigureClusterMutation'; + +// handle the form submission +export async function handleSubmitConfigureCluster( + values: ConfigureFormValues, + initialValues: ConfigureFormValues | undefined, + configureClusterMutation: UseMutationResult< + void, + unknown, + ConfigureClusterPayloads, + unknown + >, + { resetForm }: FormikHelpers, + trackEvent: (action: string, properties: TrackEventProps) => void, + environment?: Environment +) { + if (!environment) { + notifyError('Unable to save configuration: environment not found'); + return; + } + + // send metrics if needed + if ( + values.restrictDefaultNamespace && + !initialValues?.restrictDefaultNamespace + ) { + trackEvent('kubernetes-configure', { + category: 'kubernetes', + metadata: { + restrictAccessToDefaultNamespace: values.restrictDefaultNamespace, + }, + }); + } + + // transform the form values into the environment object + const selectedStorageClasses = values.storageClasses.filter( + (storageClass) => storageClass.selected + ); + const updatedEnvironment = assignFormValuesToEnvironment( + environment, + values, + selectedStorageClasses + ); + const storageClassPatches = createStorageClassPatches( + selectedStorageClasses, + initialValues?.storageClasses + ); + + // update the environment using a react query mutation + await configureClusterMutation.mutateAsync( + { + id: environment.Id, + updateEnvironmentPayload: updatedEnvironment, + ingressControllers: + values.ingressClasses as IngressControllerClassMapRowData[], + storageClassPatches, + }, + { + onSuccess: () => { + notifySuccess('Success', 'Configuration successfully applied'); + resetForm(); + }, + } + ); +} + +function createStorageClassPatches( + storageClasses: StorageClassFormValues[], + oldStorageClasses?: StorageClassFormValues[] +) { + const storageClassPatches = storageClasses.flatMap((storageClass) => { + const oldStorageClass = oldStorageClasses?.find( + (sc) => sc.Name === storageClass.Name + ); + if (!oldStorageClass) { + return []; + } + const newPayload = createStorageClassPayload(storageClass); + const oldPayload = createStorageClassPayload(oldStorageClass); + const patch = compare(oldPayload, newPayload); + return [{ name: storageClass.Name, patch }]; + }); + return storageClassPatches; +} + +function createStorageClassPayload(storageClass: StorageClassFormValues) { + const payload: StorageClass = { + provisioner: storageClass.Provisioner, + allowVolumeExpansion: storageClass.AllowVolumeExpansion, + metadata: { + uid: '', + name: storageClass.Name, + namespace: '', + labels: {}, + annotations: {}, + }, + }; + return payload; +} + +function assignFormValuesToEnvironment( + environment: Environment, + values: ConfigureFormValues, + selectedStorageClasses: StorageClassFormValues[] +) { + // note that the ingress datatable form values are omitted and included in another call + const updatedEnvironment: Partial = { + Kubernetes: { + ...environment.Kubernetes, + Configuration: { + ...environment.Kubernetes.Configuration, + UseLoadBalancer: values.useLoadBalancer, + UseServerMetrics: values.useServerMetrics, + EnableResourceOverCommit: values.enableResourceOverCommit, + ResourceOverCommitPercentage: values.resourceOverCommitPercentage, + RestrictDefaultNamespace: values.restrictDefaultNamespace, + RestrictStandardUserIngressW: values.restrictStandardUserIngressW, + IngressAvailabilityPerNamespace: values.ingressAvailabilityPerNamespace, + AllowNoneIngressClass: values.allowNoneIngressClass, + StorageClasses: selectedStorageClasses.map((storageClass) => ({ + Name: storageClass.Name, + AccessModes: storageClass.AccessModes.map( + (accessMode) => accessMode.Name + ), + AllowVolumeExpansion: storageClass.AllowVolumeExpansion, + Provisioner: storageClass.Provisioner, + })), + }, + }, + }; + return updatedEnvironment; +} diff --git a/app/react/kubernetes/cluster/ConfigureView/ConfigureForm/index.ts b/app/react/kubernetes/cluster/ConfigureView/ConfigureForm/index.ts new file mode 100644 index 000000000..aaab16ea2 --- /dev/null +++ b/app/react/kubernetes/cluster/ConfigureView/ConfigureForm/index.ts @@ -0,0 +1 @@ +export { ConfigureForm } from './ConfigureForm'; diff --git a/app/react/kubernetes/cluster/ConfigureView/ConfigureForm/types.ts b/app/react/kubernetes/cluster/ConfigureView/ConfigureForm/types.ts new file mode 100644 index 000000000..5572845ef --- /dev/null +++ b/app/react/kubernetes/cluster/ConfigureView/ConfigureForm/types.ts @@ -0,0 +1,28 @@ +import { IngressControllerClassMap } from '../../ingressClass/types'; + +export type AccessMode = { + Description: string; + Name: string; + selected: boolean; +}; + +export type StorageClassFormValues = { + Name: string; + AccessModes: AccessMode[]; + Provisioner: string; + AllowVolumeExpansion: boolean; + selected: boolean; +}; + +export type ConfigureFormValues = { + useLoadBalancer: boolean; + useServerMetrics: boolean; + enableResourceOverCommit: boolean; + resourceOverCommitPercentage: number; + restrictDefaultNamespace: boolean; + restrictStandardUserIngressW: boolean; + ingressAvailabilityPerNamespace: boolean; + allowNoneIngressClass: boolean; + storageClasses: StorageClassFormValues[]; + ingressClasses: IngressControllerClassMap[]; +}; diff --git a/app/react/kubernetes/cluster/ConfigureView/ConfigureForm/useConfigureClusterMutation.ts b/app/react/kubernetes/cluster/ConfigureView/ConfigureForm/useConfigureClusterMutation.ts new file mode 100644 index 000000000..7cb84443d --- /dev/null +++ b/app/react/kubernetes/cluster/ConfigureView/ConfigureForm/useConfigureClusterMutation.ts @@ -0,0 +1,72 @@ +import { useMutation, useQueryClient } from 'react-query'; +import { Operation } from 'fast-json-patch'; + +import { withError, withInvalidate } from '@/react-tools/react-query'; +import { environmentQueryKeys } from '@/react/portainer/environments/queries/query-keys'; +import { + UpdateEnvironmentPayload, + updateEnvironment, +} from '@/react/portainer/environments/queries/useUpdateEnvironmentMutation'; +import axios from '@/portainer/services/axios'; +import { parseKubernetesAxiosError } from '@/react/kubernetes/axiosError'; + +import { updateIngressControllerClassMap } from '../../ingressClass/useIngressControllerClassMap'; +import { IngressControllerClassMapRowData } from '../../ingressClass/types'; + +export type ConfigureClusterPayloads = { + id: number; + updateEnvironmentPayload: Partial; + ingressControllers: IngressControllerClassMapRowData[]; + storageClassPatches: { + name: string; + patch: Operation[]; + }[]; +}; + +// useConfigureClusterMutation updates the environment, the ingress classes and the storage classes +export function useConfigureClusterMutation() { + const queryClient = useQueryClient(); + return useMutation( + async ({ + id, + updateEnvironmentPayload, + ingressControllers, + storageClassPatches, + }: ConfigureClusterPayloads) => { + await updateEnvironment({ id, payload: updateEnvironmentPayload }); + await Promise.all( + storageClassPatches.map(({ name, patch }) => + patchStorageClass(id, name, patch) + ) + ); + await updateIngressControllerClassMap(id, ingressControllers); + }, + { + ...withInvalidate(queryClient, [environmentQueryKeys.base()]), + ...withError('Unable to apply configuration', 'Failure'), + } + ); +} + +async function patchStorageClass( + environmentId: number, + name: string, + storageClassPatch: Operation[] +) { + try { + await axios.patch( + `/endpoints/${environmentId}/kubernetes/apis/storage.k8s.io/v1/storageclasses/${name}`, + storageClassPatch, + { + headers: { + 'Content-Type': 'application/json-patch+json', + }, + } + ); + } catch (e) { + throw parseKubernetesAxiosError( + e as Error, + `Unable to patch StorageClass ${name}` + ); + } +} diff --git a/app/react/kubernetes/cluster/ConfigureView/ConfigureForm/useStorageClassesFormValues.ts b/app/react/kubernetes/cluster/ConfigureView/ConfigureForm/useStorageClassesFormValues.ts new file mode 100644 index 000000000..b7631aa91 --- /dev/null +++ b/app/react/kubernetes/cluster/ConfigureView/ConfigureForm/useStorageClassesFormValues.ts @@ -0,0 +1,111 @@ +import { useQuery } from 'react-query'; +import { StorageClass, StorageClassList } from 'kubernetes-types/storage/v1'; + +import axios from '@/portainer/services/axios'; +import { + Environment, + EnvironmentId, +} from '@/react/portainer/environments/types'; +import { withError } from '@/react-tools/react-query'; + +import { parseKubernetesAxiosError } from '../../../axiosError'; + +import { AccessMode, StorageClassFormValues } from './types'; + +export const availableStorageClassPolicies = [ + { + Name: 'RWO', + Description: 'Allow read-write from a single pod only (RWO)', + selected: true, + }, + { + Name: 'RWX', + Description: + 'Allow read-write access from one or more pods concurrently (RWX)', + selected: false, + }, +]; + +export function useStorageClassesFormValues( + environment: Environment | null | undefined +) { + 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); + const storageClassFormValues = transformStorageClassesToFormValues( + storageClasses, + environment + ); + return storageClassFormValues; + }, + { + ...withError('Failure', `Unable to get Storage Classes`), + enabled: !!environment, + } + ); +} + +async function getStorageClasses( + environmentId: EnvironmentId +): Promise { + try { + const { data: storageClassList } = await axios.get( + `/endpoints/${environmentId}/kubernetes/apis/storage.k8s.io/v1/storageclasses` + ); + return storageClassList.items; + } catch (e) { + throw parseKubernetesAxiosError( + e as Error, + 'Unable to retrieve Storage Classes' + ); + } +} + +function transformStorageClassesToFormValues( + storageClasses: StorageClass[], + environment: Environment +) { + const storageClassFormValues: StorageClassFormValues[] = storageClasses.map( + (storageClass) => { + const enabledStorage = + environment.Kubernetes.Configuration.StorageClasses?.find( + (sc) => sc.Name === storageClass.metadata?.name + ); + let selected = false; + let AccessModes: AccessMode[] = []; + if (enabledStorage) { + selected = true; + AccessModes = + enabledStorage.AccessModes.flatMap( + (name) => + availableStorageClassPolicies.find( + (accessMode) => accessMode.Name === name + ) || [] + ) || []; + } else { + // set a default access mode if the storage class is not enabled and there are available access modes + AccessModes = [availableStorageClassPolicies[0]]; + } + + return { + Name: storageClass.metadata?.name || '', + Provisioner: storageClass.provisioner, + AllowVolumeExpansion: !!storageClass.allowVolumeExpansion, + selected, + AccessModes, + }; + } + ); + return storageClassFormValues; +} diff --git a/app/react/kubernetes/cluster/ConfigureView/ConfigureForm/validation.ts b/app/react/kubernetes/cluster/ConfigureView/ConfigureForm/validation.ts new file mode 100644 index 000000000..cbff9563b --- /dev/null +++ b/app/react/kubernetes/cluster/ConfigureView/ConfigureForm/validation.ts @@ -0,0 +1,62 @@ +import { object, string, boolean, array, number, SchemaOf } from 'yup'; + +import { IngressControllerClassMap } from '../../ingressClass/types'; + +import { ConfigureFormValues } from './types'; + +// Define Yup schema for AccessMode +const accessModeSchema = object().shape({ + Description: string().required(), + Name: string().required(), + selected: boolean().required(), +}); + +// Define Yup schema for StorageClassFormValues +const storageClassFormValuesSchema = array() + .of( + object().shape({ + Name: string().required(), + AccessModes: array().of(accessModeSchema).required(), + Provisioner: string().required(), + AllowVolumeExpansion: boolean().required(), + selected: boolean().required(), + }) + ) + .test( + // invalid if any storage class is not selected or if it's selected and at least one access mode is selected + 'accessModes', + 'Shared access policy configuration required.', + (storageClasses) => { + const isValid = storageClasses?.every( + (value) => + !value.selected || + value.AccessModes?.some((accessMode) => accessMode.selected) + ); + return isValid || false; + } + ); + +// Define Yup schema for IngressControllerClassMap +const ingressControllerClassMapSchema: SchemaOf = + object().shape({ + Name: string().required(), + ClassName: string().required(), + Type: string().required(), + Availability: boolean().required(), + New: boolean().required(), + Used: boolean().required(), + }); + +// Define Yup schema for ConfigureFormValues +export const configureValidationSchema: SchemaOf = object({ + useLoadBalancer: boolean().required(), + useServerMetrics: boolean().required(), + enableResourceOverCommit: boolean().required(), + resourceOverCommitPercentage: number().required(), + restrictDefaultNamespace: boolean().required(), + restrictStandardUserIngressW: boolean().required(), + ingressAvailabilityPerNamespace: boolean().required(), + allowNoneIngressClass: boolean().required(), + storageClasses: storageClassFormValuesSchema.required(), + ingressClasses: array().of(ingressControllerClassMapSchema).required(), +}); diff --git a/app/react/kubernetes/cluster/ConfigureView/ConfigureView.tsx b/app/react/kubernetes/cluster/ConfigureView/ConfigureView.tsx new file mode 100644 index 000000000..804d25858 --- /dev/null +++ b/app/react/kubernetes/cluster/ConfigureView/ConfigureView.tsx @@ -0,0 +1,39 @@ +import { useCurrentEnvironment } from '@/react/hooks/useCurrentEnvironment'; + +import { PageHeader } from '@@/PageHeader'; +import { Widget, WidgetBody } from '@@/Widget'; + +import { ConfigureForm } from './ConfigureForm'; + +export function ConfigureView() { + const { data: environment } = useCurrentEnvironment(); + + // get the initial values + + return ( + <> + +
+
+ + + + + +
+
+ + ); +} diff --git a/app/react/kubernetes/cluster/ConfigureView/index.ts b/app/react/kubernetes/cluster/ConfigureView/index.ts new file mode 100644 index 000000000..155f7be56 --- /dev/null +++ b/app/react/kubernetes/cluster/ConfigureView/index.ts @@ -0,0 +1 @@ +export { ConfigureView } from './ConfigureView'; diff --git a/app/react/kubernetes/cluster/getIsRBACEnabled.ts b/app/react/kubernetes/cluster/getIsRBACEnabled.ts index 972016d42..67751786d 100644 --- a/app/react/kubernetes/cluster/getIsRBACEnabled.ts +++ b/app/react/kubernetes/cluster/getIsRBACEnabled.ts @@ -1,6 +1,20 @@ +import { useQuery } from 'react-query'; + import PortainerError from '@/portainer/error'; import axios from '@/portainer/services/axios'; import { EnvironmentId } from '@/react/portainer/environments/types'; +import { withError } from '@/react-tools/react-query'; + +export function useIsRBACEnabledQuery(environmentId: EnvironmentId) { + return useQuery( + ['environments', environmentId, 'rbacEnabled'], + () => getIsRBACEnabled(environmentId), + { + enabled: !!environmentId, + ...withError('Unable to check if RBAC is enabled.'), + } + ); +} export async function getIsRBACEnabled(environmentId: EnvironmentId) { try { diff --git a/app/react/kubernetes/cluster/ingressClass/IngressClassDatatable/IngressClassDatatable.tsx b/app/react/kubernetes/cluster/ingressClass/IngressClassDatatable/IngressClassDatatable.tsx index d9eaef5a6..e75e35950 100644 --- a/app/react/kubernetes/cluster/ingressClass/IngressClassDatatable/IngressClassDatatable.tsx +++ b/app/react/kubernetes/cluster/ingressClass/IngressClassDatatable/IngressClassDatatable.tsx @@ -10,7 +10,7 @@ import { createPersistedStore } from '@@/datatables/types'; import { buildConfirmButton } from '@@/modals/utils'; import { useTableState } from '@@/datatables/useTableState'; -import { IngressControllerClassMap } from '../types'; +import { IngressControllerClassMapRowData } from '../types'; import { columns } from './columns'; @@ -19,10 +19,11 @@ const settingsStore = createPersistedStore(storageKey, 'name'); interface Props { onChangeControllers: ( - controllerClassMap: IngressControllerClassMap[] + controllerClassMap: IngressControllerClassMapRowData[] ) => void; // angular function to save the ingress class list description: string; - ingressControllers: IngressControllerClassMap[] | undefined; + ingressControllers: IngressControllerClassMapRowData[] | undefined; + initialIngressControllers: IngressControllerClassMapRowData[] | undefined; allowNoneIngressClass: boolean; isLoading: boolean; noIngressControllerLabel: string; @@ -32,6 +33,7 @@ interface Props { export function IngressClassDatatable({ onChangeControllers, description, + initialIngressControllers, ingressControllers, allowNoneIngressClass, isLoading, @@ -44,12 +46,23 @@ export function IngressClassDatatable({ ingressControllers || [] ); + // set the ingress controller form values when the ingress controller list changes + // and the ingress controller form values are not set useEffect(() => { - if (allowNoneIngressClass === undefined) { + if ( + ingressControllers && + ingControllerFormValues.length !== ingressControllers.length + ) { + setIngControllerFormValues(ingressControllers); + } + }, [ingressControllers, ingControllerFormValues]); + + useEffect(() => { + if (allowNoneIngressClass === undefined || isLoading) { return; } - let newIngFormValues: IngressControllerClassMap[]; + let newIngFormValues: IngressControllerClassMapRowData[]; const isCustomTypeExist = ingControllerFormValues.some( (ic) => ic.Type === 'custom' ); @@ -93,7 +106,9 @@ export function IngressClassDatatable({ ); - function renderTableActions(selectedRows: IngressControllerClassMap[]) { + function renderTableActions( + selectedRows: IngressControllerClassMapRowData[] + ) { return (
@@ -140,9 +155,12 @@ export function IngressClassDatatable({ return (
{description}
- {ingressControllers && + {initialIngressControllers && ingControllerFormValues && - isUnsavedChanges(ingressControllers, ingControllerFormValues) && ( + isUnsavedChanges( + initialIngressControllers, + ingControllerFormValues + ) && ( Unsaved changes. @@ -153,8 +171,8 @@ export function IngressClassDatatable({ } async function updateIngressControllers( - selectedRows: IngressControllerClassMap[], - ingControllerFormValues: IngressControllerClassMap[], + selectedRows: IngressControllerClassMapRowData[], + ingControllerFormValues: IngressControllerClassMapRowData[], availability: boolean ) { const updatedIngressControllers = getUpdatedIngressControllers( @@ -222,8 +240,8 @@ export function IngressClassDatatable({ } function isUnsavedChanges( - oldIngressControllers: IngressControllerClassMap[], - newIngressControllers: IngressControllerClassMap[] + oldIngressControllers: IngressControllerClassMapRowData[], + newIngressControllers: IngressControllerClassMapRowData[] ) { if (oldIngressControllers.length !== newIngressControllers.length) { return true; @@ -240,8 +258,8 @@ function isUnsavedChanges( } function getUpdatedIngressControllers( - selectedRows: IngressControllerClassMap[], - allRows: IngressControllerClassMap[], + selectedRows: IngressControllerClassMapRowData[], + allRows: IngressControllerClassMapRowData[], allow: boolean ) { const selectedRowClassNames = selectedRows.map((row) => row.ClassName); diff --git a/app/react/kubernetes/cluster/ingressClass/IngressClassDatatable/columns/availability.tsx b/app/react/kubernetes/cluster/ingressClass/IngressClassDatatable/columns/availability.tsx index 0c78b901a..485fb8ffc 100644 --- a/app/react/kubernetes/cluster/ingressClass/IngressClassDatatable/columns/availability.tsx +++ b/app/react/kubernetes/cluster/ingressClass/IngressClassDatatable/columns/availability.tsx @@ -4,7 +4,7 @@ import { Check, X } from 'lucide-react'; import { Badge } from '@@/Badge'; import { Icon } from '@@/Icon'; -import type { IngressControllerClassMap } from '../../types'; +import type { IngressControllerClassMapRowData } from '../../types'; import { columnHelper } from './helper'; @@ -16,7 +16,9 @@ export const availability = columnHelper.accessor('Availability', { sortingFn: 'basic', }); -function Cell({ getValue }: CellContext) { +function Cell({ + getValue, +}: CellContext) { const availability = getValue(); return ( diff --git a/app/react/kubernetes/cluster/ingressClass/IngressClassDatatable/columns/helper.ts b/app/react/kubernetes/cluster/ingressClass/IngressClassDatatable/columns/helper.ts index 9014d0eaa..3cb62e247 100644 --- a/app/react/kubernetes/cluster/ingressClass/IngressClassDatatable/columns/helper.ts +++ b/app/react/kubernetes/cluster/ingressClass/IngressClassDatatable/columns/helper.ts @@ -1,5 +1,6 @@ import { createColumnHelper } from '@tanstack/react-table'; -import { IngressControllerClassMap } from '../../types'; +import { IngressControllerClassMapRowData } from '../../types'; -export const columnHelper = createColumnHelper(); +export const columnHelper = + createColumnHelper(); diff --git a/app/react/kubernetes/cluster/ingressClass/IngressClassDatatable/columns/name.tsx b/app/react/kubernetes/cluster/ingressClass/IngressClassDatatable/columns/name.tsx index be22a5362..66fc1ab9f 100644 --- a/app/react/kubernetes/cluster/ingressClass/IngressClassDatatable/columns/name.tsx +++ b/app/react/kubernetes/cluster/ingressClass/IngressClassDatatable/columns/name.tsx @@ -2,7 +2,7 @@ import { CellContext } from '@tanstack/react-table'; import { Badge } from '@@/Badge'; -import type { IngressControllerClassMap } from '../../types'; +import type { IngressControllerClassMapRowData } from '../../types'; import { columnHelper } from './helper'; @@ -15,7 +15,7 @@ export const name = columnHelper.accessor('ClassName', { function NameCell({ row, getValue, -}: CellContext) { +}: CellContext) { const className = getValue(); return ( diff --git a/app/react/kubernetes/cluster/ingressClass/IngressClassDatatable/types.ts b/app/react/kubernetes/cluster/ingressClass/IngressClassDatatable/types.ts index 7f64de9f2..f9903c8ba 100644 --- a/app/react/kubernetes/cluster/ingressClass/IngressClassDatatable/types.ts +++ b/app/react/kubernetes/cluster/ingressClass/IngressClassDatatable/types.ts @@ -6,3 +6,24 @@ import { export interface TableSettings extends SortableTableSettings, PaginationTableSettings {} + +export type SupportedIngControllerTypes = + | 'nginx' + | 'traefik' + | 'other' + | 'custom'; + +// Not having 'extends Record' fixes validation type errors from yup +export interface IngressControllerClassMap { + Name: string; + ClassName: string; + Type: string; + Availability: boolean; + New: boolean; + Used: boolean; // if the controller is used by any ingress in the cluster +} + +// Record fixes type errors when using the type with a react datatable +export interface IngressControllerClassMapRowData + extends Record, + IngressControllerClassMap {} diff --git a/app/react/kubernetes/cluster/ingressClass/utils/index.ts b/app/react/kubernetes/cluster/ingressClass/IngressClassDatatable/useIngressControllerClassMap.ts similarity index 61% rename from app/react/kubernetes/cluster/ingressClass/utils/index.ts rename to app/react/kubernetes/cluster/ingressClass/IngressClassDatatable/useIngressControllerClassMap.ts index 6f5949222..40a347843 100644 --- a/app/react/kubernetes/cluster/ingressClass/utils/index.ts +++ b/app/react/kubernetes/cluster/ingressClass/IngressClassDatatable/useIngressControllerClassMap.ts @@ -1,8 +1,45 @@ +import { useQuery } from 'react-query'; + import { EnvironmentId } from '@/react/portainer/environments/types'; import PortainerError from '@/portainer/error'; import axios from '@/portainer/services/axios'; +import { withError } from '@/react-tools/react-query'; -import { IngressControllerClassMap } from '../types'; +import { IngressControllerClassMapRowData } from './types'; + +export function useIngressControllerClassMapQuery({ + environmentId, + namespace, + allowedOnly, +}: { + environmentId?: EnvironmentId; + namespace?: string; + allowedOnly?: boolean; +}) { + return useQuery( + [ + 'environments', + environmentId, + 'ingresscontrollers', + namespace, + allowedOnly, + ], + () => { + if (!environmentId) { + return []; + } + return getIngressControllerClassMap({ + environmentId, + namespace, + allowedOnly, + }); + }, + { + ...withError('Failure', 'Unable to get ingress controllers.'), + enabled: !!environmentId, + } + ); +} // get all supported ingress classes and controllers for the cluster // allowedOnly set to true will hide globally disallowed ingresscontrollers @@ -17,7 +54,7 @@ export async function getIngressControllerClassMap({ }) { try { const { data: controllerMaps } = await axios.get< - IngressControllerClassMap[] + IngressControllerClassMapRowData[] >( buildUrl(environmentId, namespace), allowedOnly ? { params: { allowedOnly: true } } : undefined @@ -31,12 +68,12 @@ export async function getIngressControllerClassMap({ // get all supported ingress classes and controllers for the cluster export async function updateIngressControllerClassMap( environmentId: EnvironmentId, - ingressControllerClassMap: IngressControllerClassMap[], + ingressControllerClassMap: IngressControllerClassMapRowData[], namespace?: string ) { try { const { data: controllerMaps } = await axios.put< - IngressControllerClassMap[] + IngressControllerClassMapRowData[] >(buildUrl(environmentId, namespace), ingressControllerClassMap); return controllerMaps; } catch (e) { diff --git a/app/react/kubernetes/cluster/ingressClass/types.ts b/app/react/kubernetes/cluster/ingressClass/types.ts index 9e981ef03..44238c776 100644 --- a/app/react/kubernetes/cluster/ingressClass/types.ts +++ b/app/react/kubernetes/cluster/ingressClass/types.ts @@ -4,11 +4,17 @@ export type SupportedIngControllerTypes = | 'other' | 'custom'; -export interface IngressControllerClassMap extends Record { +// Not having 'extends Record' fixes validation type errors from yup +export interface IngressControllerClassMap { Name: string; ClassName: string; - Type: SupportedIngControllerTypes; + Type: string; Availability: boolean; New: boolean; Used: boolean; // if the controller is used by any ingress in the cluster } + +// Record fixes type errors when using the type with a react datatable +export interface IngressControllerClassMapRowData + extends Record, + IngressControllerClassMap {} diff --git a/app/react/kubernetes/cluster/ingressClass/useIngressControllerClassMap.ts b/app/react/kubernetes/cluster/ingressClass/useIngressControllerClassMap.ts new file mode 100644 index 000000000..40a347843 --- /dev/null +++ b/app/react/kubernetes/cluster/ingressClass/useIngressControllerClassMap.ts @@ -0,0 +1,94 @@ +import { useQuery } from 'react-query'; + +import { EnvironmentId } from '@/react/portainer/environments/types'; +import PortainerError from '@/portainer/error'; +import axios from '@/portainer/services/axios'; +import { withError } from '@/react-tools/react-query'; + +import { IngressControllerClassMapRowData } from './types'; + +export function useIngressControllerClassMapQuery({ + environmentId, + namespace, + allowedOnly, +}: { + environmentId?: EnvironmentId; + namespace?: string; + allowedOnly?: boolean; +}) { + return useQuery( + [ + 'environments', + environmentId, + 'ingresscontrollers', + namespace, + allowedOnly, + ], + () => { + if (!environmentId) { + return []; + } + return getIngressControllerClassMap({ + environmentId, + namespace, + allowedOnly, + }); + }, + { + ...withError('Failure', 'Unable to get ingress controllers.'), + enabled: !!environmentId, + } + ); +} + +// get all supported ingress classes and controllers for the cluster +// allowedOnly set to true will hide globally disallowed ingresscontrollers +export async function getIngressControllerClassMap({ + environmentId, + namespace, + allowedOnly, +}: { + environmentId: EnvironmentId; + namespace?: string; + allowedOnly?: boolean; +}) { + try { + const { data: controllerMaps } = await axios.get< + IngressControllerClassMapRowData[] + >( + buildUrl(environmentId, namespace), + allowedOnly ? { params: { allowedOnly: true } } : undefined + ); + return controllerMaps; + } catch (e) { + throw new PortainerError('Unable to get ingress controllers.', e as Error); + } +} + +// get all supported ingress classes and controllers for the cluster +export async function updateIngressControllerClassMap( + environmentId: EnvironmentId, + ingressControllerClassMap: IngressControllerClassMapRowData[], + namespace?: string +) { + try { + const { data: controllerMaps } = await axios.put< + IngressControllerClassMapRowData[] + >(buildUrl(environmentId, namespace), ingressControllerClassMap); + return controllerMaps; + } catch (e) { + throw new PortainerError( + 'Unable to update ingress controllers.', + e as Error + ); + } +} + +function buildUrl(environmentId: EnvironmentId, namespace?: string) { + let url = `kubernetes/${environmentId}/`; + if (namespace) { + url += `namespaces/${namespace}/`; + } + url += 'ingresscontrollers'; + return url; +} diff --git a/app/react/kubernetes/queries/useGetMetricsMutation.ts b/app/react/kubernetes/queries/useGetMetricsMutation.ts new file mode 100644 index 000000000..cc9ce4821 --- /dev/null +++ b/app/react/kubernetes/queries/useGetMetricsMutation.ts @@ -0,0 +1,8 @@ +import { useMutation } from 'react-query'; + +import { getMetricsForAllNodes } from '../services/service'; + +// use this as a mutation because the metrics request should be manually fired when the user clicks to turn the metrics toggle on +export function useGetMetricsMutation() { + return useMutation(getMetricsForAllNodes); +} diff --git a/app/react/portainer/environments/common/TimeWindowPicker/TimePickerInput.tsx b/app/react/portainer/environments/common/TimeWindowPicker/TimePickerInput.tsx new file mode 100644 index 000000000..81be79f40 --- /dev/null +++ b/app/react/portainer/environments/common/TimeWindowPicker/TimePickerInput.tsx @@ -0,0 +1,109 @@ +import { ChevronUp, ChevronDown } from 'lucide-react'; +import moment from 'moment'; + +import { Button } from '@@/buttons'; +import { Input } from '@@/form-components/Input'; + +import { utcToTimeZone } from './utils'; + +const valueFormat = 'HH:mm'; +const displayFormat = 'hh:mm'; +const minuteIncrement = 5; + +type Props = { + utcTime: string; + onChange: (time: string) => void; + timeZone?: string; +}; + +export function TimePickerInput({ + utcTime, + onChange, + timeZone = 'UTC', +}: Props) { + const localTime12h = utcToTimeZone(utcTime, timeZone, displayFormat); + const localTime24h = utcToTimeZone(utcTime, timeZone, valueFormat); + const [hours, minutes] = localTime12h.split(':'); + + return ( +
+
+
+ : +
+
+ +
+ ); +} diff --git a/app/react/portainer/environments/common/TimeWindowPicker/TimeWindowPicker.tsx b/app/react/portainer/environments/common/TimeWindowPicker/TimeWindowPicker.tsx new file mode 100644 index 000000000..fce1258fc --- /dev/null +++ b/app/react/portainer/environments/common/TimeWindowPicker/TimeWindowPicker.tsx @@ -0,0 +1,99 @@ +import moment from 'moment'; + +import { Button } from '@@/buttons'; +import { Alert } from '@@/Alert'; + +import { EndpointChangeWindow } from '../../types'; + +import { TimeWindowPickerInputGroup } from './TimeWindowPickerInputGroup'; +import { formatUTCTime, utcToTimeZone } from './utils'; + +type Props = { + /** + * The current start and end time values. in 'HH:mm' format (e.g. '00:00') and in UTC timezone. + */ + values: EndpointChangeWindow; + initialValues: EndpointChangeWindow; + onChange: ({ + changeWindow, + timeZone, + }: { + changeWindow: EndpointChangeWindow; + timeZone?: string; + }) => void; + isEditMode: boolean; + setIsEditMode: (isEditMode: boolean) => void; + timeZone?: string; + initialTimeZone?: string; +}; + +const summaryTimeFormat = 'h:mmA'; + +export function TimeWindowPicker({ + values, + initialValues, + onChange, + isEditMode, + setIsEditMode, + timeZone = moment.tz.guess(), + initialTimeZone, +}: Props) { + return ( +
+ {isEditMode && ( + + )} + + + GitOps updates to stacks or applications outside{' '} + {`${formatUTCTime( + values.StartTime, + summaryTimeFormat + )} - ${formatUTCTime( + values.EndTime, + summaryTimeFormat + )} UTC (${utcToTimeZone( + values.StartTime, + timeZone, + summaryTimeFormat + )} - ${utcToTimeZone(values.EndTime, timeZone, summaryTimeFormat)} ${ + moment().isDST() ? ' DST' : '' + } ${timeZone})`}{' '} + will not occur. + + + {values.Enabled && ( +
+ {!isEditMode && ( + + )} + {isEditMode && ( + + )} +
+ )} +
+ ); +} diff --git a/app/react/portainer/environments/common/TimeWindowPicker/TimeWindowPickerInputGroup.tsx b/app/react/portainer/environments/common/TimeWindowPicker/TimeWindowPickerInputGroup.tsx new file mode 100644 index 000000000..78553168d --- /dev/null +++ b/app/react/portainer/environments/common/TimeWindowPicker/TimeWindowPickerInputGroup.tsx @@ -0,0 +1,130 @@ +import moment from 'moment'; +import { useMemo } from 'react'; + +import { Select } from '@@/form-components/ReactSelect'; +import { Option } from '@@/form-components/PortainerSelect'; + +import { EndpointChangeWindow } from '../../types'; + +import { timeZoneToUtc, utcToTimeZone } from './utils'; +import { TimePickerInput } from './TimePickerInput'; + +type Props = { + /** + * The current start and end time values. in 'HH:mm' format (e.g. '00:00') and in UTC timezone. + */ + values: EndpointChangeWindow; + onChange: ({ + changeWindow, + timeZone, + }: { + changeWindow: EndpointChangeWindow; + timeZone: string; + }) => void; + timeZone?: string; +}; + +export function TimeWindowPickerInputGroup({ + values, + onChange, + timeZone = moment.tz.guess(), +}: Props) { + // all unique timezones for all countries as options + const timeZoneOptions = useMemo(() => { + const countries = moment.tz.countries(); + const zones = countries.flatMap((country) => + moment.tz.zonesForCountry(country) + ); + return [...new Set(zones)] + .sort() + .concat('UTC') + .map((zone) => ({ + label: zone, + value: zone, + })); + }, []); + + // set the initial timezone to the user's timezone if it is not set + if (!timeZone) { + const newTimeZone = moment.tz.guess(); + onChange({ + changeWindow: { + ...values, + StartTime: timeZoneToUtc(values.StartTime, newTimeZone), + EndTime: timeZoneToUtc(values.EndTime, newTimeZone), + }, + timeZone: newTimeZone, + }); + } + + return ( +
+
+ + onChange({ + changeWindow: { + ...values, + StartTime: timeZoneToUtc(time, timeZone), + }, + timeZone, + }) + } + /> + to + + onChange({ + changeWindow: { + ...values, + EndTime: timeZoneToUtc(time, timeZone), + }, + timeZone, + }) + } + /> +
+ > + options={timeZoneOptions} + value={{ value: timeZone, label: timeZone }} + className="w-72 min-w-fit" + onChange={(newTimeZone) => { + if (!newTimeZone) return; + // update the utc time so that the local time displayed remains the same + const updatedStartTime = onTimezoneChangeUpdateUTCTime( + values.StartTime, + timeZone, + newTimeZone.value + ); + const updatedEndTime = onTimezoneChangeUpdateUTCTime( + values.EndTime, + timeZone, + newTimeZone.value + ); + onChange({ + changeWindow: { + ...values, + StartTime: updatedStartTime, + EndTime: updatedEndTime, + }, + timeZone: newTimeZone.value, + }); + }} + /> +
+ ); +} + +function onTimezoneChangeUpdateUTCTime( + utcTime: string, + oldTimeZone: string, + newTimeZone: string +) { + const localTime = utcToTimeZone(utcTime, oldTimeZone); + const newUtcTime = timeZoneToUtc(localTime, newTimeZone); + return newUtcTime; +} diff --git a/app/react/portainer/environments/common/TimeWindowPicker/index.ts b/app/react/portainer/environments/common/TimeWindowPicker/index.ts new file mode 100644 index 000000000..c61d736aa --- /dev/null +++ b/app/react/portainer/environments/common/TimeWindowPicker/index.ts @@ -0,0 +1 @@ +export { TimeWindowPicker } from './TimeWindowPicker'; diff --git a/app/react/portainer/environments/common/TimeWindowPicker/utils.ts b/app/react/portainer/environments/common/TimeWindowPicker/utils.ts new file mode 100644 index 000000000..4560b74ef --- /dev/null +++ b/app/react/portainer/environments/common/TimeWindowPicker/utils.ts @@ -0,0 +1,36 @@ +import moment from 'moment'; + +/** + * Converts a UTC time to the same format in the given timezone. + * @param utcTime The UTC time to convert in 'HH:mm' format. + * @param timeZone The timezone to convert the UTC time to. + * @param format The format to convert the time to. + * @returns The converted time in the same format as the input. + */ +export function utcToTimeZone( + utcTime: string, + timeZone: string, + format = 'HH:mm' +) { + return moment.utc(utcTime, 'HH:mm').tz(timeZone).format(format); +} + +/** + * Converts a time in the given timezone to the same format in UTC. + * @param time The time to convert in 'HH:mm' format. + * @param timeZone The timezone to convert the time to UTC. + * @returns The converted time in the same format as the input. + */ +export function timeZoneToUtc(time: string, timeZone: string) { + return moment.tz(time, 'HH:mm', timeZone).utc().format('HH:mm'); +} + +/** + * Formats a UTC time string to the specified format. + * @param utcTime - The UTC time string to format in 'HH:mm' format. + * @param format - The format to use. Defaults to 'HH:mm'. + * @returns The formatted time string. + */ +export function formatUTCTime(utcTime: string, format = 'HH:mm') { + return moment.utc(utcTime, 'HH:mm').format(format); +} diff --git a/app/react/portainer/environments/queries/query-keys.ts b/app/react/portainer/environments/queries/query-keys.ts index 4dd971478..ed0600c19 100644 --- a/app/react/portainer/environments/queries/query-keys.ts +++ b/app/react/portainer/environments/queries/query-keys.ts @@ -1,8 +1,8 @@ import { EnvironmentId } from '../types'; -export const queryKeys = { +export const environmentQueryKeys = { base: () => ['environments'] as const, - item: (id: EnvironmentId) => [...queryKeys.base(), id] as const, + item: (id: EnvironmentId) => [...environmentQueryKeys.base(), id] as const, registries: (environmentId: EnvironmentId) => - [...queryKeys.base(), environmentId, 'registries'] as const, + [...environmentQueryKeys.base(), environmentId, 'registries'] as const, }; diff --git a/app/react/portainer/environments/queries/useAgentVersionsList.ts b/app/react/portainer/environments/queries/useAgentVersionsList.ts index 3e51d85e4..c4fe926bc 100644 --- a/app/react/portainer/environments/queries/useAgentVersionsList.ts +++ b/app/react/portainer/environments/queries/useAgentVersionsList.ts @@ -2,10 +2,10 @@ import { useQuery } from 'react-query'; import { getAgentVersions } from '../environment.service'; -import { queryKeys } from './query-keys'; +import { environmentQueryKeys } from './query-keys'; export function useAgentVersionsList() { - return useQuery([...queryKeys.base(), 'agentVersions'], () => + return useQuery([...environmentQueryKeys.base(), 'agentVersions'], () => getAgentVersions() ); } diff --git a/app/react/portainer/environments/queries/useEnvironment.ts b/app/react/portainer/environments/queries/useEnvironment.ts index 556b16bfa..21ac5a570 100644 --- a/app/react/portainer/environments/queries/useEnvironment.ts +++ b/app/react/portainer/environments/queries/useEnvironment.ts @@ -7,14 +7,14 @@ import { } from '@/react/portainer/environments/types'; import { withError } from '@/react-tools/react-query'; -import { queryKeys } from './query-keys'; +import { environmentQueryKeys } from './query-keys'; export function useEnvironment( id?: EnvironmentId, select?: (environment: Environment | null) => T ) { return useQuery( - id ? queryKeys.item(id) : [], + id ? environmentQueryKeys.item(id) : [], () => (id ? getEndpoint(id) : null), { select, diff --git a/app/react/portainer/environments/queries/useEnvironmentList.ts b/app/react/portainer/environments/queries/useEnvironmentList.ts index 64432d53d..966dab122 100644 --- a/app/react/portainer/environments/queries/useEnvironmentList.ts +++ b/app/react/portainer/environments/queries/useEnvironmentList.ts @@ -8,7 +8,7 @@ import { getEnvironments, } from '../environment.service'; -import { queryKeys } from './query-keys'; +import { environmentQueryKeys } from './query-keys'; export const ENVIRONMENTS_POLLING_INTERVAL = 30000; // in ms @@ -60,7 +60,7 @@ export function useEnvironmentList( ) { const { isLoading, data } = useQuery( [ - ...queryKeys.base(), + ...environmentQueryKeys.base(), { page, pageLimit, diff --git a/app/react/portainer/environments/queries/useEnvironmentRegistries.ts b/app/react/portainer/environments/queries/useEnvironmentRegistries.ts index 9fc535cc6..a465ff053 100644 --- a/app/react/portainer/environments/queries/useEnvironmentRegistries.ts +++ b/app/react/portainer/environments/queries/useEnvironmentRegistries.ts @@ -5,14 +5,14 @@ import { EnvironmentId } from '../types'; import { Registry } from '../../registries/types/registry'; import { useGenericRegistriesQuery } from '../../registries/queries/useRegistries'; -import { queryKeys } from './query-keys'; +import { environmentQueryKeys } from './query-keys'; export function useEnvironmentRegistries>( environmentId: EnvironmentId, queryOptions: { select?(data: Array): T; enabled?: boolean } = {} ) { return useGenericRegistriesQuery( - queryKeys.registries(environmentId), + environmentQueryKeys.registries(environmentId), () => getEnvironmentRegistries(environmentId), queryOptions ); diff --git a/app/react/portainer/environments/queries/useUpdateEnvironmentMutation.ts b/app/react/portainer/environments/queries/useUpdateEnvironmentMutation.ts index ecb474124..aeb99a832 100644 --- a/app/react/portainer/environments/queries/useUpdateEnvironmentMutation.ts +++ b/app/react/portainer/environments/queries/useUpdateEnvironmentMutation.ts @@ -5,6 +5,9 @@ import { EnvironmentId, EnvironmentStatusMessage, Environment, + KubernetesSettings, + DeploymentOptions, + EndpointChangeWindow, } from '@/react/portainer/environments/types'; import axios, { parseAxiosError } from '@/portainer/services/axios'; import { TagId } from '@/portainer/tags/types'; @@ -12,17 +15,17 @@ import { TagId } from '@/portainer/tags/types'; import { EnvironmentGroupId } from '../environment-groups/types'; import { buildUrl } from '../environment.service/utils'; -import { queryKeys } from './query-keys'; +import { environmentQueryKeys } from './query-keys'; export function useUpdateEnvironmentMutation() { const queryClient = useQueryClient(); return useMutation(updateEnvironment, { - ...withInvalidate(queryClient, [queryKeys.base()]), + ...withInvalidate(queryClient, [environmentQueryKeys.base()]), ...withError('Unable to update environment'), }); } -export interface UpdatePayload { +export interface UpdateEnvironmentPayload extends Partial { TLSCACert?: File; TLSCert?: File; TLSKey?: File; @@ -42,15 +45,18 @@ export interface UpdatePayload { AzureAuthenticationKey: string; IsSetStatusMessage: boolean; - StatusMessage: Partial; + StatusMessage: EnvironmentStatusMessage; + Kubernetes?: KubernetesSettings; + DeploymentOptions?: DeploymentOptions | null; + ChangeWindow?: EndpointChangeWindow; } -async function updateEnvironment({ +export async function updateEnvironment({ id, payload, }: { id: EnvironmentId; - payload: Partial; + payload: Partial; }) { try { await uploadTLSFilesForEndpoint( diff --git a/app/react/portainer/environments/queries/useUpdateEnvironmentsRelationsMutation.ts b/app/react/portainer/environments/queries/useUpdateEnvironmentsRelationsMutation.ts index da4a7d4f5..d080a8500 100644 --- a/app/react/portainer/environments/queries/useUpdateEnvironmentsRelationsMutation.ts +++ b/app/react/portainer/environments/queries/useUpdateEnvironmentsRelationsMutation.ts @@ -16,7 +16,7 @@ import { EnvironmentId } from '../types'; import { buildUrl } from '../environment.service/utils'; import { EnvironmentGroupId } from '../environment-groups/types'; -import { queryKeys } from './query-keys'; +import { environmentQueryKeys } from './query-keys'; export function useUpdateEnvironmentsRelationsMutation() { const queryClient = useQueryClient(); @@ -25,7 +25,7 @@ export function useUpdateEnvironmentsRelationsMutation() { updateEnvironmentRelations, mutationOptions( withInvalidate(queryClient, [ - queryKeys.base(), + environmentQueryKeys.base(), edgeGroupQueryKeys.base(), groupQueryKeys.base(), tagKeys.all, diff --git a/app/react/portainer/environments/types.ts b/app/react/portainer/environments/types.ts index a41e39b55..464370b6c 100644 --- a/app/react/portainer/environments/types.ts +++ b/app/react/portainer/environments/types.ts @@ -127,7 +127,7 @@ export type DeploymentOptions = { /** * EndpointChangeWindow determine when GitOps stack/app updates may occur */ -interface EndpointChangeWindow { +export interface EndpointChangeWindow { Enabled: boolean; StartTime: string; EndTime: string; diff --git a/app/react/portainer/environments/wizard/EnvironmentTypeSelectView/EndpointTypeView.tsx b/app/react/portainer/environments/wizard/EnvironmentTypeSelectView/EndpointTypeView.tsx index bf23911be..99cb58669 100644 --- a/app/react/portainer/environments/wizard/EnvironmentTypeSelectView/EndpointTypeView.tsx +++ b/app/react/portainer/environments/wizard/EnvironmentTypeSelectView/EndpointTypeView.tsx @@ -3,7 +3,7 @@ import { useRouter } from '@uirouter/react'; import _ from 'lodash'; import { Wand2 } from 'lucide-react'; -import { useAnalytics } from '@/angulartics.matomo/analytics-services'; +import { useAnalytics } from '@/react/hooks/useAnalytics'; import { Button } from '@@/buttons'; import { PageHeader } from '@@/PageHeader'; diff --git a/app/react/portainer/environments/wizard/EnvironmentsCreationView/EnvironmentsCreationView.tsx b/app/react/portainer/environments/wizard/EnvironmentsCreationView/EnvironmentsCreationView.tsx index e0209b41e..4fae4f007 100644 --- a/app/react/portainer/environments/wizard/EnvironmentsCreationView/EnvironmentsCreationView.tsx +++ b/app/react/portainer/environments/wizard/EnvironmentsCreationView/EnvironmentsCreationView.tsx @@ -9,7 +9,7 @@ import { Environment, EnvironmentId, } from '@/react/portainer/environments/types'; -import { useAnalytics } from '@/angulartics.matomo/analytics-services'; +import { useAnalytics } from '@/react/hooks/useAnalytics'; import { Stepper } from '@@/Stepper'; import { Widget, WidgetBody, WidgetTitle } from '@@/Widget'; diff --git a/app/react/portainer/environments/wizard/HomeView/HomeView.tsx b/app/react/portainer/environments/wizard/HomeView/HomeView.tsx index f39887558..68d93b220 100644 --- a/app/react/portainer/environments/wizard/HomeView/HomeView.tsx +++ b/app/react/portainer/environments/wizard/HomeView/HomeView.tsx @@ -1,7 +1,7 @@ import { Wand2, Plug2 } from 'lucide-react'; import { EnvironmentType } from '@/react/portainer/environments/types'; -import { useAnalytics } from '@/angulartics.matomo/analytics-services'; +import { useAnalytics } from '@/react/hooks/useAnalytics'; import DockerIcon from '@/assets/ico/vendor/docker-icon.svg?c'; import Kube from '@/assets/ico/kube.svg?c'; diff --git a/app/react/sidebar/KubernetesSidebar/KubectlShell/KubectlShellButton.tsx b/app/react/sidebar/KubernetesSidebar/KubectlShell/KubectlShellButton.tsx index c03f1882e..2864b7cf4 100644 --- a/app/react/sidebar/KubernetesSidebar/KubectlShell/KubectlShellButton.tsx +++ b/app/react/sidebar/KubernetesSidebar/KubectlShell/KubectlShellButton.tsx @@ -4,7 +4,7 @@ import { createPortal } from 'react-dom'; import { Terminal } from 'lucide-react'; import { EnvironmentId } from '@/react/portainer/environments/types'; -import { useAnalytics } from '@/angulartics.matomo/analytics-services'; +import { useAnalytics } from '@/react/hooks/useAnalytics'; import { Button } from '@@/buttons'; import { Icon } from '@@/Icon'; diff --git a/app/react/sidebar/UpgradeBEBanner/GetLicenseDialog.tsx b/app/react/sidebar/UpgradeBEBanner/GetLicenseDialog.tsx index 4ae25e95a..cf04a27a4 100644 --- a/app/react/sidebar/UpgradeBEBanner/GetLicenseDialog.tsx +++ b/app/react/sidebar/UpgradeBEBanner/GetLicenseDialog.tsx @@ -1,4 +1,4 @@ -import { useAnalytics } from '@/angulartics.matomo/analytics-services'; +import { useAnalytics } from '@/react/hooks/useAnalytics'; import { HubspotForm } from '@@/HubspotForm'; import { Modal } from '@@/modals/Modal'; diff --git a/app/react/sidebar/UpgradeBEBanner/UpgradeBEBanner.tsx b/app/react/sidebar/UpgradeBEBanner/UpgradeBEBanner.tsx index 5d1fce59a..e0f2e393f 100644 --- a/app/react/sidebar/UpgradeBEBanner/UpgradeBEBanner.tsx +++ b/app/react/sidebar/UpgradeBEBanner/UpgradeBEBanner.tsx @@ -2,7 +2,7 @@ import { ArrowUpCircle } from 'lucide-react'; import { useState } from 'react'; import clsx from 'clsx'; -import { useAnalytics } from '@/angulartics.matomo/analytics-services'; +import { useAnalytics } from '@/react/hooks/useAnalytics'; import { useNodesCount } from '@/react/portainer/system/useNodesCount'; import { ContainerPlatform, diff --git a/app/react/sidebar/UpgradeBEBanner/UploadLicenseDialog.tsx b/app/react/sidebar/UpgradeBEBanner/UploadLicenseDialog.tsx index 942943e47..c103a78bc 100644 --- a/app/react/sidebar/UpgradeBEBanner/UploadLicenseDialog.tsx +++ b/app/react/sidebar/UpgradeBEBanner/UploadLicenseDialog.tsx @@ -3,7 +3,7 @@ import { object, SchemaOf, string } from 'yup'; import { useUpgradeEditionMutation } from '@/react/portainer/system/useUpgradeEditionMutation'; import { notifySuccess } from '@/portainer/services/notifications'; -import { useAnalytics } from '@/angulartics.matomo/analytics-services'; +import { useAnalytics } from '@/react/hooks/useAnalytics'; import { Button, LoadingButton } from '@@/buttons'; import { FormControl } from '@@/form-components/FormControl';