From 7dc6a1559f502634faf4289e525a89cb2f8043e9 Mon Sep 17 00:00:00 2001 From: Chaim Lev-Ari Date: Tue, 20 Jun 2023 11:02:39 +0700 Subject: [PATCH] refactor(settings): kube settings panel [EE-5504] (#9079) --- app/portainer/react/components/settings.ts | 5 + app/portainer/views/settings/settings.html | 87 +------------- .../views/settings/settingsController.js | 48 -------- .../ApplicationSettingsPanel/LogoFieldset.tsx | 3 +- .../ScreenBannerFieldset.tsx | 3 +- .../DeploymentOptionsSection.tsx | 85 ++++++++++++++ .../KubeSettingsPanel/HelmSection.tsx | 37 ++++++ .../KubeSettingsPanel/KubeConfigSection.tsx | 45 ++++++++ .../KubeNoteMinimumCharacters.tsx | 61 ++++++++++ .../KubeSettingsPanel/KubeSettingsPanel.tsx | 109 ++++++++++++++++++ .../SettingsView/KubeSettingsPanel/index.ts | 1 + .../SettingsView/KubeSettingsPanel/types.ts | 12 ++ .../KubeSettingsPanel/validation.ts | 32 +++++ .../useToggledValue.tsx | 7 +- app/react/portainer/settings/types.ts | 4 + 15 files changed, 401 insertions(+), 138 deletions(-) create mode 100644 app/react/portainer/settings/SettingsView/KubeSettingsPanel/DeploymentOptionsSection.tsx create mode 100644 app/react/portainer/settings/SettingsView/KubeSettingsPanel/HelmSection.tsx create mode 100644 app/react/portainer/settings/SettingsView/KubeSettingsPanel/KubeConfigSection.tsx create mode 100644 app/react/portainer/settings/SettingsView/KubeSettingsPanel/KubeNoteMinimumCharacters.tsx create mode 100644 app/react/portainer/settings/SettingsView/KubeSettingsPanel/KubeSettingsPanel.tsx create mode 100644 app/react/portainer/settings/SettingsView/KubeSettingsPanel/index.ts create mode 100644 app/react/portainer/settings/SettingsView/KubeSettingsPanel/types.ts create mode 100644 app/react/portainer/settings/SettingsView/KubeSettingsPanel/validation.ts rename app/react/portainer/settings/SettingsView/{ApplicationSettingsPanel => }/useToggledValue.tsx (80%) diff --git a/app/portainer/react/components/settings.ts b/app/portainer/react/components/settings.ts index 16304bbd5..5082a3252 100644 --- a/app/portainer/react/components/settings.ts +++ b/app/portainer/react/components/settings.ts @@ -7,6 +7,7 @@ import { r2a } from '@/react-tools/react2angular'; import { withReactQuery } from '@/react-tools/withReactQuery'; import { withUIRouter } from '@/react-tools/withUIRouter'; import { ApplicationSettingsPanel } from '@/react/portainer/settings/SettingsView/ApplicationSettingsPanel'; +import { KubeSettingsPanel } from '@/react/portainer/settings/SettingsView/KubeSettingsPanel'; export const settingsModule = angular .module('portainer.app.react.components.settings', []) @@ -22,4 +23,8 @@ export const settingsModule = angular .component( 'applicationSettingsPanel', r2a(withReactQuery(ApplicationSettingsPanel), ['onSuccess']) + ) + .component( + 'kubeSettingsPanel', + r2a(withUIRouter(withReactQuery(KubeSettingsPanel)), []) ).name; diff --git a/app/portainer/views/settings/settings.html b/app/portainer/views/settings/settings.html index 9e990907e..05d30603a 100644 --- a/app/portainer/views/settings/settings.html +++ b/app/portainer/views/settings/settings.html @@ -2,92 +2,7 @@ -
-
- - - -
- -
Helm Repository
-
-
- - You can specify the URL to your own helm repository here. See the - official documentation for more details. - -
-
- -
- -
-
-
- - -
Kubeconfig
-
- -
- -
-
- - -
Deployment Options
-
- -
-
- -
- - -
-
- -
-
- -
-
-
-
-
+ diff --git a/app/portainer/views/settings/settingsController.js b/app/portainer/views/settings/settingsController.js index 7c09aec28..0309640f8 100644 --- a/app/portainer/views/settings/settingsController.js +++ b/app/portainer/views/settings/settingsController.js @@ -1,7 +1,5 @@ import angular from 'angular'; -import { FeatureId } from '@/react/portainer/feature-flags/enums'; - angular.module('portainer.app').controller('SettingsController', [ '$scope', 'Notifications', @@ -10,40 +8,13 @@ angular.module('portainer.app').controller('SettingsController', [ function ($scope, Notifications, SettingsService, StateManager) { $scope.updateSettings = updateSettings; $scope.handleSuccess = handleSuccess; - $scope.requireNoteOnApplications = FeatureId.K8S_REQUIRE_NOTE_ON_APPLICATIONS; $scope.state = { actionInProgress: false, - availableKubeconfigExpiryOptions: [ - { - key: '1 day', - value: '24h', - }, - { - key: '7 days', - value: `${24 * 7}h`, - }, - { - key: '30 days', - value: `${24 * 30}h`, - }, - { - key: '1 year', - value: `${24 * 30 * 12}h`, - }, - { - key: 'No expiry', - value: '0', - }, - ], - backupInProgress: false, - featureLimited: false, showHTTPS: !window.ddExtension, }; $scope.formValues = { - KubeconfigExpiry: undefined, - HelmRepositoryURL: undefined, BlackListedLabels: [], labelName: '', labelValue: '', @@ -66,18 +37,6 @@ angular.module('portainer.app').controller('SettingsController', [ updateSettings(filteredSettingsPayload, 'Hidden container settings updated'); }; - // only update the values from the kube settings widget. In future separate the api endpoints - $scope.saveKubernetesSettings = function () { - const kubeSettingsPayload = { - KubeconfigExpiry: $scope.formValues.KubeconfigExpiry, - HelmRepositoryURL: $scope.formValues.HelmRepositoryURL, - GlobalDeploymentOptions: $scope.formValues.GlobalDeploymentOptions, - }; - - $scope.state.kubeSettingsActionInProgress = true; - updateSettings(kubeSettingsPayload, 'Kubernetes settings updated'); - }; - function updateSettings(settings, successMessage = 'Settings updated') { return SettingsService.update(settings) .then(function success(settings) { @@ -88,7 +47,6 @@ angular.module('portainer.app').controller('SettingsController', [ Notifications.error('Failure', err, 'Unable to update settings'); }) .finally(function final() { - $scope.state.kubeSettingsActionInProgress = false; $scope.state.actionInProgress = false; }); } @@ -100,10 +58,6 @@ angular.module('portainer.app').controller('SettingsController', [ StateManager.updateEnableTelemetry(settings.EnableTelemetry); $scope.formValues.BlackListedLabels = settings.BlackListedLabels; } - - // trigger an event to update the deployment options for the react based sidebar - const event = new CustomEvent('portainer:deploymentOptionsUpdated'); - document.dispatchEvent(event); } function initView() { @@ -112,8 +66,6 @@ angular.module('portainer.app').controller('SettingsController', [ var settings = data; $scope.settings = settings; - $scope.formValues.KubeconfigExpiry = settings.KubeconfigExpiry; - $scope.formValues.HelmRepositoryURL = settings.HelmRepositoryURL; $scope.formValues.BlackListedLabels = settings.BlackListedLabels; }) .catch(function error(err) { diff --git a/app/react/portainer/settings/SettingsView/ApplicationSettingsPanel/LogoFieldset.tsx b/app/react/portainer/settings/SettingsView/ApplicationSettingsPanel/LogoFieldset.tsx index 99d027e40..e22d7e98b 100644 --- a/app/react/portainer/settings/SettingsView/ApplicationSettingsPanel/LogoFieldset.tsx +++ b/app/react/portainer/settings/SettingsView/ApplicationSettingsPanel/LogoFieldset.tsx @@ -6,7 +6,8 @@ import { FormControl } from '@@/form-components/FormControl'; import { Input } from '@@/form-components/Input'; import { SwitchField } from '@@/form-components/SwitchField'; -import { useToggledValue } from './useToggledValue'; +import { useToggledValue } from '../useToggledValue'; + import { DemoAlert } from './DemoAlert'; export function LogoFieldset() { diff --git a/app/react/portainer/settings/SettingsView/ApplicationSettingsPanel/ScreenBannerFieldset.tsx b/app/react/portainer/settings/SettingsView/ApplicationSettingsPanel/ScreenBannerFieldset.tsx index 43ea3b010..cc1590ee1 100644 --- a/app/react/portainer/settings/SettingsView/ApplicationSettingsPanel/ScreenBannerFieldset.tsx +++ b/app/react/portainer/settings/SettingsView/ApplicationSettingsPanel/ScreenBannerFieldset.tsx @@ -7,7 +7,8 @@ import { FormControl } from '@@/form-components/FormControl'; import { TextArea } from '@@/form-components/Input/Textarea'; import { SwitchField } from '@@/form-components/SwitchField'; -import { useToggledValue } from './useToggledValue'; +import { useToggledValue } from '../useToggledValue'; + import { DemoAlert } from './DemoAlert'; export function ScreenBannerFieldset() { diff --git a/app/react/portainer/settings/SettingsView/KubeSettingsPanel/DeploymentOptionsSection.tsx b/app/react/portainer/settings/SettingsView/KubeSettingsPanel/DeploymentOptionsSection.tsx new file mode 100644 index 000000000..ae03283ee --- /dev/null +++ b/app/react/portainer/settings/SettingsView/KubeSettingsPanel/DeploymentOptionsSection.tsx @@ -0,0 +1,85 @@ +import { useFormikContext } from 'formik'; + +import { FeatureId } from '@/react/portainer/feature-flags/enums'; +import { isLimitedToBE } from '@/react/portainer/feature-flags/feature-flags.service'; + +import { FormSection } from '@@/form-components/FormSection'; +import { SwitchField } from '@@/form-components/SwitchField'; + +import { KubeNoteMinimumCharacters } from './KubeNoteMinimumCharacters'; +import { FormValues } from './types'; + +export function DeploymentOptionsSection() { + const { + values: { globalDeploymentOptions: values }, + setFieldValue, + } = useFormikContext(); + const limitedFeature = isLimitedToBE(FeatureId.ENFORCE_DEPLOYMENT_OPTIONS); + return ( + +
+
+ handleToggleAddWithForm(value)} + labelClass="col-sm-3 col-lg-2" + tooltip="Hides the 'Add with form' buttons and prevents adding/editing of resources via forms" + /> +
+
+ {values.hideAddWithForm && ( +
+
+ + setFieldValue('globalDeploymentOptions.hideWebEditor', !value) + } + labelClass="col-sm-2 !pl-4" + /> +
+
+ + setFieldValue('globalDeploymentOptions.hideFileUpload', !value) + } + labelClass="col-sm-2 !pl-4" + /> +
+
+ )} + {!limitedFeature && ( +
+
+ + setFieldValue('globalDeploymentOptions.perEnvOverride', value) + } + name="toggle_perEnvOverride" + labelClass="col-sm-3 col-lg-2" + tooltip="Allows overriding of deployment options in the Cluster setup screen of each environment" + /> +
+
+ )} + + +
+ ); + + async function handleToggleAddWithForm(checked: boolean) { + await setFieldValue('globalDeploymentOptions.hideWebEditor', checked); + await setFieldValue('globalDeploymentOptions.hideFileUpload', checked); + await setFieldValue('globalDeploymentOptions.hideAddWithForm', checked); + } +} diff --git a/app/react/portainer/settings/SettingsView/KubeSettingsPanel/HelmSection.tsx b/app/react/portainer/settings/SettingsView/KubeSettingsPanel/HelmSection.tsx new file mode 100644 index 000000000..38428c8e6 --- /dev/null +++ b/app/react/portainer/settings/SettingsView/KubeSettingsPanel/HelmSection.tsx @@ -0,0 +1,37 @@ +import { Field, useField } from 'formik'; + +import { TextTip } from '@@/Tip/TextTip'; +import { FormControl } from '@@/form-components/FormControl'; +import { FormSection } from '@@/form-components/FormSection'; +import { Input } from '@@/form-components/Input'; + +export function HelmSection() { + const [{ name }, { error }] = useField('helmRepositoryUrl'); + + return ( + +
+ + You can specify the URL to your own helm repository here. See the{' '} + + official documentation + {' '} + for more details. + +
+ + + + +
+ ); +} diff --git a/app/react/portainer/settings/SettingsView/KubeSettingsPanel/KubeConfigSection.tsx b/app/react/portainer/settings/SettingsView/KubeSettingsPanel/KubeConfigSection.tsx new file mode 100644 index 000000000..42a7bb569 --- /dev/null +++ b/app/react/portainer/settings/SettingsView/KubeSettingsPanel/KubeConfigSection.tsx @@ -0,0 +1,45 @@ +import { useField } from 'formik'; + +import { FormControl } from '@@/form-components/FormControl'; +import { FormSection } from '@@/form-components/FormSection'; +import { PortainerSelect } from '@@/form-components/PortainerSelect'; + +const options = [ + { + label: '1 day', + value: '24h', + }, + { + label: '7 days', + value: `${24 * 7}h`, + }, + { + label: '30 days', + value: `${24 * 30}h`, + }, + { + label: '1 year', + value: `${24 * 30 * 12}h`, + }, + { + label: 'No expiry', + value: '0', + }, +] as const; + +export function KubeConfigSection() { + const [{ value }, { error }, { setValue }] = + useField('kubeconfigExpiry'); + + return ( + + + value && setValue(value)} + /> + + + ); +} diff --git a/app/react/portainer/settings/SettingsView/KubeSettingsPanel/KubeNoteMinimumCharacters.tsx b/app/react/portainer/settings/SettingsView/KubeSettingsPanel/KubeNoteMinimumCharacters.tsx new file mode 100644 index 000000000..abca146f7 --- /dev/null +++ b/app/react/portainer/settings/SettingsView/KubeSettingsPanel/KubeNoteMinimumCharacters.tsx @@ -0,0 +1,61 @@ +import { useField } from 'formik'; + +import { FeatureId } from '@/react/portainer/feature-flags/enums'; +import { isBE } from '@/react/portainer/feature-flags/feature-flags.service'; + +import { FormControl } from '@@/form-components/FormControl'; +import { SwitchField } from '@@/form-components/SwitchField'; +import { Input } from '@@/form-components/Input'; + +import { useToggledValue } from '../useToggledValue'; + +export function KubeNoteMinimumCharacters() { + const [{ value }, { error }, { setValue }] = useField( + 'globalDeploymentOptions.minApplicationNoteLength' + ); + const [isEnabled, setIsEnabled] = useToggledValue( + 'globalDeploymentOptions.minApplicationNoteLength', + 'globalDeploymentOptions.requireNoteOnApplications' + ); + + return ( + <> +
+
+ setIsEnabled(value)} + featureId={FeatureId.K8S_REQUIRE_NOTE_ON_APPLICATIONS} + labelClass="col-sm-3 col-lg-2" + tooltip={`${ + isBE ? '' : 'BE allows entry of notes in Add/Edit application. ' + }Using this will enforce entry of a note in Add/Edit application (and prevent complete clearing of it in Application details).`} + /> +
+
+ {isEnabled && ( + + Minimum number of characters note must have + + } + errors={error} + > + setValue(e.target.valueAsNumber)} + className="w-1/4" + /> + + )} + + ); +} diff --git a/app/react/portainer/settings/SettingsView/KubeSettingsPanel/KubeSettingsPanel.tsx b/app/react/portainer/settings/SettingsView/KubeSettingsPanel/KubeSettingsPanel.tsx new file mode 100644 index 000000000..7f9831b31 --- /dev/null +++ b/app/react/portainer/settings/SettingsView/KubeSettingsPanel/KubeSettingsPanel.tsx @@ -0,0 +1,109 @@ +import { Form, Formik } from 'formik'; +import { useQueryClient } from 'react-query'; + +import kubeIcon from '@/assets/ico/kube.svg?c'; +import { notifySuccess } from '@/portainer/services/notifications'; +import { useEnvironmentId } from '@/react/hooks/useEnvironmentId'; + +import { LoadingButton } from '@@/buttons'; +import { Widget } from '@@/Widget'; + +import { useSettings, useUpdateSettingsMutation } from '../../queries'; + +import { HelmSection } from './HelmSection'; +import { KubeConfigSection } from './KubeConfigSection'; +import { FormValues } from './types'; +import { DeploymentOptionsSection } from './DeploymentOptionsSection'; +import { validation } from './validation'; + +export function KubeSettingsPanel() { + const settingsQuery = useSettings(); + const queryClient = useQueryClient(); + const environmentId = useEnvironmentId(false); + const mutation = useUpdateSettingsMutation(); + + if (!settingsQuery.data) { + return null; + } + + const initialValues: FormValues = { + helmRepositoryUrl: settingsQuery.data.HelmRepositoryURL || '', + kubeconfigExpiry: settingsQuery.data.KubeconfigExpiry || '0', + globalDeploymentOptions: settingsQuery.data.GlobalDeploymentOptions || { + requireNoteOnApplications: false, + minApplicationNoteLength: 0, + hideAddWithForm: false, + hideFileUpload: false, + hideWebEditor: false, + perEnvOverride: false, + }, + }; + + return ( +
+
+ + + + + {() => ( +
+ + + + +
+
+ + Save Kubernetes Settings + +
+
+ + )} +
+
+
+
+
+ ); + + function handleSubmit(values: FormValues) { + mutation.mutate( + { + HelmRepositoryURL: values.helmRepositoryUrl, + KubeconfigExpiry: values.kubeconfigExpiry, + GlobalDeploymentOptions: { + ...values.globalDeploymentOptions, + requireNoteOnApplications: + values.globalDeploymentOptions.requireNoteOnApplications, + minApplicationNoteLength: values.globalDeploymentOptions + .requireNoteOnApplications + ? values.globalDeploymentOptions.minApplicationNoteLength + : 0, + }, + }, + { + async onSuccess() { + if (environmentId) { + await queryClient.invalidateQueries([ + 'environments', + environmentId, + 'deploymentOptions', + ]); + } + notifySuccess('Success', 'Kubernetes settings updated'); + }, + } + ); + } +} diff --git a/app/react/portainer/settings/SettingsView/KubeSettingsPanel/index.ts b/app/react/portainer/settings/SettingsView/KubeSettingsPanel/index.ts new file mode 100644 index 000000000..2bfb0427e --- /dev/null +++ b/app/react/portainer/settings/SettingsView/KubeSettingsPanel/index.ts @@ -0,0 +1 @@ +export { KubeSettingsPanel } from './KubeSettingsPanel'; diff --git a/app/react/portainer/settings/SettingsView/KubeSettingsPanel/types.ts b/app/react/portainer/settings/SettingsView/KubeSettingsPanel/types.ts new file mode 100644 index 000000000..1fc4b6710 --- /dev/null +++ b/app/react/portainer/settings/SettingsView/KubeSettingsPanel/types.ts @@ -0,0 +1,12 @@ +export interface FormValues { + helmRepositoryUrl: string; + kubeconfigExpiry: string; + globalDeploymentOptions: { + hideAddWithForm: boolean; + perEnvOverride: boolean; + hideWebEditor: boolean; + hideFileUpload: boolean; + requireNoteOnApplications: boolean; + minApplicationNoteLength: number; + }; +} diff --git a/app/react/portainer/settings/SettingsView/KubeSettingsPanel/validation.ts b/app/react/portainer/settings/SettingsView/KubeSettingsPanel/validation.ts new file mode 100644 index 000000000..d9a6717cb --- /dev/null +++ b/app/react/portainer/settings/SettingsView/KubeSettingsPanel/validation.ts @@ -0,0 +1,32 @@ +import { SchemaOf, object, string, boolean, number } from 'yup'; + +import { isValidUrl } from '@@/form-components/validate-url'; + +import { FormValues } from './types'; + +export function validation(): SchemaOf { + return object().shape({ + helmRepositoryUrl: string() + .default('') + .test('valid-url', 'Invalid URL', (value) => !value || isValidUrl(value)), + kubeconfigExpiry: string().required(), + globalDeploymentOptions: object().shape({ + hideAddWithForm: boolean().required(), + perEnvOverride: boolean().required(), + hideWebEditor: boolean().required(), + hideFileUpload: boolean().required(), + requireNoteOnApplications: boolean().required(), + minApplicationNoteLength: number() + .typeError('Must be a number') + .default(0) + .when('requireNoteOnApplications', { + is: true, + then: (schema) => + schema + .required() + .min(1, 'Value should be between 1 to 9999') + .max(9999, 'Value should be between 1 to 9999'), + }), + }), + }); +} diff --git a/app/react/portainer/settings/SettingsView/ApplicationSettingsPanel/useToggledValue.tsx b/app/react/portainer/settings/SettingsView/useToggledValue.tsx similarity index 80% rename from app/react/portainer/settings/SettingsView/ApplicationSettingsPanel/useToggledValue.tsx rename to app/react/portainer/settings/SettingsView/useToggledValue.tsx index 0da74e1d0..8d1312ec1 100644 --- a/app/react/portainer/settings/SettingsView/ApplicationSettingsPanel/useToggledValue.tsx +++ b/app/react/portainer/settings/SettingsView/useToggledValue.tsx @@ -1,10 +1,13 @@ import { useField } from 'formik'; import { useState } from 'react'; -export function useToggledValue(fieldName: string) { +export function useToggledValue( + fieldName: string, + toggleFieldName = `${fieldName}Enabled` +) { const [, { value }, { setValue }] = useField(fieldName); const [, { value: isEnabled }, { setValue: setIsEnabled }] = - useField(`${fieldName}Enabled`); + useField(toggleFieldName); const [oldValue, setOldValue] = useState(value); async function handleIsEnabledChange(enabled: boolean) { diff --git a/app/react/portainer/settings/types.ts b/app/react/portainer/settings/types.ts index d9d9b8115..61b17c3c9 100644 --- a/app/react/portainer/settings/types.ts +++ b/app/react/portainer/settings/types.ts @@ -130,6 +130,7 @@ export interface Settings { AllowStackManagementForRegularUsers: boolean; AllowDeviceMappingForRegularUsers: boolean; AllowContainerCapabilitiesForRegularUsers: boolean; + GlobalDeploymentOptions?: GlobalDeploymentOptions; Edge: { PingInterval: number; SnapshotInterval: number; @@ -148,6 +149,9 @@ interface GlobalDeploymentOptions { hideWebEditor: boolean; /** Hide the file upload option in the remaining visible forms */ hideFileUpload: boolean; + /** Make note on application add/edit screen required */ + requireNoteOnApplications: boolean; + minApplicationNoteLength: number; } export interface PublicSettingsResponse {