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 @@
-
+
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 (
+
+
+
+
+
+
+ {() => (
+
+ )}
+
+
+
+
+
+ );
+
+ 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 {