diff --git a/app/portainer/__module.js b/app/portainer/__module.js index eec3bf318..81d77fcc5 100644 --- a/app/portainer/__module.js +++ b/app/portainer/__module.js @@ -364,8 +364,7 @@ angular url: '/settings', views: { 'content@': { - templateUrl: './views/settings/settings.html', - controller: 'SettingsController', + component: 'settingsView', }, }, }; diff --git a/app/portainer/react/views/index.ts b/app/portainer/react/views/index.ts index 1fa68d3cd..e5aff680f 100644 --- a/app/portainer/react/views/index.ts +++ b/app/portainer/react/views/index.ts @@ -11,6 +11,7 @@ import { withI18nSuspense } from '@/react-tools/withI18nSuspense'; import { EdgeAutoCreateScriptView } from '@/react/portainer/environments/EdgeAutoCreateScriptView'; import { ListView as EnvironmentsListView } from '@/react/portainer/environments/ListView'; import { BackupSettingsPanel } from '@/react/portainer/settings/SettingsView/BackupSettingsView/BackupSettingsPanel'; +import { SettingsView } from '@/react/portainer/settings/SettingsView/SettingsView'; import { wizardModule } from './wizard'; import { teamsModule } from './teams'; @@ -54,4 +55,8 @@ export const viewsModule = angular .component( 'backupSettingsPanel', r2a(withUIRouter(withReactQuery(withCurrentUser(BackupSettingsPanel))), []) + ) + .component( + 'settingsView', + r2a(withUIRouter(withReactQuery(withCurrentUser(SettingsView))), []) ).name; diff --git a/app/portainer/services/types.ts b/app/portainer/services/types.ts index 0f05d0308..affeee832 100644 --- a/app/portainer/services/types.ts +++ b/app/portainer/services/types.ts @@ -2,6 +2,9 @@ import { Environment } from '@/react/portainer/environments/types'; export interface StateManager { updateEndpointState(endpoint: Environment): Promise; + updateLogo(logo: string): void; + updateSnapshotInterval(interval: string): void; + updateEnableTelemetry(enable: boolean): void; } export interface IAuthenticationService { diff --git a/app/portainer/views/settings/settings.html b/app/portainer/views/settings/settings.html deleted file mode 100644 index 9adca482f..000000000 --- a/app/portainer/views/settings/settings.html +++ /dev/null @@ -1,14 +0,0 @@ - - - - - - - - - - - - - - diff --git a/app/portainer/views/settings/settingsController.js b/app/portainer/views/settings/settingsController.js deleted file mode 100644 index 50ca80c51..000000000 --- a/app/portainer/views/settings/settingsController.js +++ /dev/null @@ -1,20 +0,0 @@ -import angular from 'angular'; - -angular.module('portainer.app').controller('SettingsController', SettingsController); - -/* @ngInject */ -function SettingsController($scope, StateManager) { - $scope.handleSuccess = handleSuccess; - - $scope.state = { - showHTTPS: !window.ddExtension, - }; - - function handleSuccess(settings) { - if (settings) { - StateManager.updateLogo(settings.LogoURL); - StateManager.updateSnapshotInterval(settings.SnapshotInterval); - StateManager.updateEnableTelemetry(settings.EnableTelemetry); - } - } -} diff --git a/app/react/portainer/settings/SettingsView/.keep b/app/react/portainer/settings/SettingsView/.keep deleted file mode 100644 index e69de29bb..000000000 diff --git a/app/react/portainer/settings/SettingsView/ApplicationSettingsPanel/ApplicationSettingsPanel.tsx b/app/react/portainer/settings/SettingsView/ApplicationSettingsPanel/ApplicationSettingsPanel.tsx index f0be07503..eac646664 100644 --- a/app/react/portainer/settings/SettingsView/ApplicationSettingsPanel/ApplicationSettingsPanel.tsx +++ b/app/react/portainer/settings/SettingsView/ApplicationSettingsPanel/ApplicationSettingsPanel.tsx @@ -47,23 +47,19 @@ export function ApplicationSettingsPanel({ }; return ( -
-
- - - - - - - - -
-
+ + + + + + + + ); function handleSubmit(values: Values) { diff --git a/app/react/portainer/settings/SettingsView/BackupSettingsView/BackupSettingsPanel.tsx b/app/react/portainer/settings/SettingsView/BackupSettingsView/BackupSettingsPanel.tsx index 76b019b2c..a052e3428 100644 --- a/app/react/portainer/settings/SettingsView/BackupSettingsView/BackupSettingsPanel.tsx +++ b/app/react/portainer/settings/SettingsView/BackupSettingsView/BackupSettingsPanel.tsx @@ -13,35 +13,31 @@ export function BackupSettingsPanel() { const [backupType, setBackupType] = useState(options[0].value); return ( -
-
- - - -
- -
- This will back up your Portainer server configuration and does - not include containers. -
- setBackupType(v)} - radioName="backup-type" - /> - - {backupType === BackupFormType.S3 ? ( - - ) : ( - - )} -
+ + + +
+ +
+ This will back up your Portainer server configuration and does not + include containers.
- - -
-
+ setBackupType(v)} + radioName="backup-type" + /> + + {backupType === BackupFormType.S3 ? ( + + ) : ( + + )} + +
+ + ); } diff --git a/app/react/portainer/settings/SettingsView/ExperimentalFeatures/EnableOpenAIIntegrationSwitch.tsx b/app/react/portainer/settings/SettingsView/ExperimentalFeatures/EnableOpenAIIntegrationSwitch.tsx new file mode 100644 index 000000000..efd8de887 --- /dev/null +++ b/app/react/portainer/settings/SettingsView/ExperimentalFeatures/EnableOpenAIIntegrationSwitch.tsx @@ -0,0 +1,31 @@ +import { useField } from 'formik'; + +import { FormControl } from '@@/form-components/FormControl'; +import { Switch } from '@@/form-components/SwitchField/Switch'; + +const fieldKey = 'OpenAIIntegration'; + +export function EnableOpenAIIntegrationSwitch() { + const [inputProps, meta, helpers] = useField(fieldKey); + + return ( + + + + ); + + function handleChange(enable: boolean) { + helpers.setValue(enable); + } +} diff --git a/app/react/portainer/settings/SettingsView/ExperimentalFeatures/ExperimentalFeatures.tsx b/app/react/portainer/settings/SettingsView/ExperimentalFeatures/ExperimentalFeatures.tsx new file mode 100644 index 000000000..519b558ba --- /dev/null +++ b/app/react/portainer/settings/SettingsView/ExperimentalFeatures/ExperimentalFeatures.tsx @@ -0,0 +1,28 @@ +import { FlaskConical } from 'lucide-react'; + +import { useExperimentalSettings } from '@/react/portainer/settings/queries'; + +import { Widget, WidgetBody, WidgetTitle } from '@@/Widget'; + +import { ExperimentalFeaturesSettingsForm } from './ExperimentalFeaturesForm'; + +export function ExperimentalFeatures() { + const settingsQuery = useExperimentalSettings(); + + if (!settingsQuery.data) { + return null; + } + + const settings = settingsQuery.data; + + return ( + + + + + + + ); +} diff --git a/app/react/portainer/settings/SettingsView/ExperimentalFeatures/ExperimentalFeaturesForm.tsx b/app/react/portainer/settings/SettingsView/ExperimentalFeatures/ExperimentalFeaturesForm.tsx new file mode 100644 index 000000000..197813091 --- /dev/null +++ b/app/react/portainer/settings/SettingsView/ExperimentalFeatures/ExperimentalFeaturesForm.tsx @@ -0,0 +1,98 @@ +import { Form, Formik } from 'formik'; +import * as yup from 'yup'; +import { useCallback } from 'react'; +import { FlaskConical } from 'lucide-react'; + +import { notifySuccess } from '@/portainer/services/notifications'; +import { ExperimentalFeatures } from '@/react/portainer/settings/types'; +import { useUpdateExperimentalSettingsMutation } from '@/react/portainer/settings/queries'; + +import { LoadingButton } from '@@/buttons/LoadingButton'; +import { TextTip } from '@@/Tip/TextTip'; + +import { EnableOpenAIIntegrationSwitch } from './EnableOpenAIIntegrationSwitch'; + +interface FormValues { + OpenAIIntegration: boolean; +} +const validation = yup.object({ + OpenAIIntegration: yup.boolean(), +}); + +interface Props { + settings: ExperimentalFeatures; +} + +export function ExperimentalFeaturesSettingsForm({ settings }: Props) { + const initialValues: FormValues = settings; + + const mutation = useUpdateExperimentalSettingsMutation(); + + const { mutate: updateSettings } = mutation; + + const handleSubmit = useCallback( + (variables: FormValues) => { + updateSettings( + { + OpenAIIntegration: variables.OpenAIIntegration, + }, + { + onSuccess() { + notifySuccess( + 'Success', + 'Successfully updated experimental features settings' + ); + }, + } + ); + }, + [updateSettings] + ); + + return ( + + initialValues={initialValues} + onSubmit={handleSubmit} + validationSchema={validation} + validateOnMount + enableReinitialize + > + {({ isValid, dirty }) => ( +
+ + Experimental features may be discontinued without notice. + + +
+
+ +
+ In Portainer releases, we may introduce features that we're + experimenting with. These will be items in the early phases of + development with limited testing. +
+ Our goal is to gain early user feedback, so we can refine, enhance + and ultimately make our features the best they can be. Disabling an + experimental feature will prevent access to it. +
+ + + +
+
+ + Save experimental settings + +
+
+ + )} + + ); +} diff --git a/app/react/portainer/settings/SettingsView/ExperimentalFeatures/index.ts b/app/react/portainer/settings/SettingsView/ExperimentalFeatures/index.ts new file mode 100644 index 000000000..a846020a7 --- /dev/null +++ b/app/react/portainer/settings/SettingsView/ExperimentalFeatures/index.ts @@ -0,0 +1 @@ +export { ExperimentalFeatures } from './ExperimentalFeatures'; diff --git a/app/react/portainer/settings/SettingsView/HelmCertPanel.tsx b/app/react/portainer/settings/SettingsView/HelmCertPanel.tsx index 3c9031ceb..ed0ead58d 100644 --- a/app/react/portainer/settings/SettingsView/HelmCertPanel.tsx +++ b/app/react/portainer/settings/SettingsView/HelmCertPanel.tsx @@ -30,28 +30,24 @@ export function HelmCertPanel() { }; return ( -
-
- - - - - - - - - - -
-
+ + + + + + + + + + ); function handleSubmit({ clientCertFile }: FormValues) { diff --git a/app/react/portainer/settings/SettingsView/HiddenContainersPanel/HiddenContainersPanel.tsx b/app/react/portainer/settings/SettingsView/HiddenContainersPanel/HiddenContainersPanel.tsx index 84806525c..a91114978 100644 --- a/app/react/portainer/settings/SettingsView/HiddenContainersPanel/HiddenContainersPanel.tsx +++ b/app/react/portainer/settings/SettingsView/HiddenContainersPanel/HiddenContainersPanel.tsx @@ -21,36 +21,30 @@ export function HiddenContainersPanel() { const labels = settingsQuery.data; return ( -
-
- - - -
- - You can hide containers with specific labels from Portainer UI. - You need to specify the label name and value. - -
+ + + +
+ + You can hide containers with specific labels from Portainer UI. You + need to specify the label name and value. + +
- - handleSubmit([...labels, { name, value }]) - } - /> + handleSubmit([...labels, { name, value }])} + /> - - handleSubmit(labels.filter((label) => label.name !== name)) - } - /> -
-
-
-
+ + handleSubmit(labels.filter((label) => label.name !== name)) + } + /> + + ); function handleSubmit(labels: Pair[]) { diff --git a/app/react/portainer/settings/SettingsView/KubeSettingsPanel/KubeSettingsPanel.tsx b/app/react/portainer/settings/SettingsView/KubeSettingsPanel/KubeSettingsPanel.tsx index 7f9831b31..931763198 100644 --- a/app/react/portainer/settings/SettingsView/KubeSettingsPanel/KubeSettingsPanel.tsx +++ b/app/react/portainer/settings/SettingsView/KubeSettingsPanel/KubeSettingsPanel.tsx @@ -40,41 +40,37 @@ export function KubeSettingsPanel() { }; return ( -
-
- - - - - {() => ( -
- - - + + + + + {() => ( + + + + -
-
- - Save Kubernetes Settings - -
-
- - )} -
-
-
-
-
+
+
+ + Save Kubernetes Settings + +
+
+ + )} + + + ); function handleSubmit(values: FormValues) { diff --git a/app/react/portainer/settings/SettingsView/SSLSettingsPanel/SSLSettingsPanel.tsx b/app/react/portainer/settings/SettingsView/SSLSettingsPanel/SSLSettingsPanel.tsx index 66013d5e5..a9917f201 100644 --- a/app/react/portainer/settings/SettingsView/SSLSettingsPanel/SSLSettingsPanel.tsx +++ b/app/react/portainer/settings/SettingsView/SSLSettingsPanel/SSLSettingsPanel.tsx @@ -43,99 +43,95 @@ function SSLSettingsPanel() { }; return ( -
-
- - - - - {({ values, setFieldValue, isValid, errors }) => ( -
-
-
- - Forcing HTTPs only will cause Portainer to stop - listening on the HTTP port. Any edge agent environment - that is using HTTP will no longer be available. - -
-
+ + + + + {({ values, setFieldValue, isValid, errors }) => ( + +
+
+ + Forcing HTTPs only will cause Portainer to stop listening on + the HTTP port. Any edge agent environment that is using HTTP + will no longer be available. + +
+
-
-
- setFieldValue('forceHTTPS', value)} - /> -
-
+
+
+ setFieldValue('forceHTTPS', value)} + /> +
+
-
-
- - Provide a new SSL Certificate to replace the existing - one that is used for HTTPS connections. - -
-
+
+
+ + Provide a new SSL Certificate to replace the existing one + that is used for HTTPS connections. + +
+
- + setFieldValue('certFile', file)} + value={values.certFile} + /> + + + + setFieldValue('keyFile', file)} + value={values.keyFile} + /> + + +
+
+ - setFieldValue('certFile', file)} - value={values.certFile} - /> - - - - setFieldValue('keyFile', file)} - value={values.keyFile} - /> - - -
-
- - Save SSL Settings - -
-
- - )} - - - -
-
+ Save SSL Settings + +
+
+ + )} + + + ); function handleSubmit({ certFile, forceHTTPS, keyFile }: FormValues) { diff --git a/app/react/portainer/settings/SettingsView/SettingsView.tsx b/app/react/portainer/settings/SettingsView/SettingsView.tsx new file mode 100644 index 000000000..00c7e40a8 --- /dev/null +++ b/app/react/portainer/settings/SettingsView/SettingsView.tsx @@ -0,0 +1,54 @@ +import angular from 'angular'; + +import { StateManager } from '@/portainer/services/types'; + +import { PageHeader } from '@@/PageHeader'; + +import { Settings } from '../types'; +import { isBE } from '../../feature-flags/feature-flags.service'; + +import { ApplicationSettingsPanel } from './ApplicationSettingsPanel'; +import { BackupSettingsPanel } from './BackupSettingsView'; +import { HelmCertPanel } from './HelmCertPanel'; +import { HiddenContainersPanel } from './HiddenContainersPanel/HiddenContainersPanel'; +import { KubeSettingsPanel } from './KubeSettingsPanel'; +import { SSLSettingsPanelWrapper } from './SSLSettingsPanel/SSLSettingsPanel'; +import { ExperimentalFeatures } from './ExperimentalFeatures'; + +export function SettingsView() { + return ( + <> + + +
+ + + + + + + + + {isBE && } + + + + +
+ + ); +} + +function handleSuccess(settings: Settings) { + // to sync "outside state" - for angularjs + // this is a hack, but it works + // state manager should be replaced with a non angular solution, maybe using zustand + const $injector = angular.element(document).injector(); + $injector.invoke( + /* @ngInject */ (StateManager: StateManager) => { + StateManager?.updateLogo(settings.LogoURL); + StateManager?.updateSnapshotInterval(settings.SnapshotInterval); + StateManager?.updateEnableTelemetry(settings.EnableTelemetry); + } + ); +} diff --git a/app/react/portainer/settings/queries/index.ts b/app/react/portainer/settings/queries/index.ts new file mode 100644 index 000000000..6f7664308 --- /dev/null +++ b/app/react/portainer/settings/queries/index.ts @@ -0,0 +1,9 @@ +export { queryKeys } from './queryKeys'; +export { + useSettings, + useUpdateDefaultRegistrySettingsMutation, + useUpdateSettingsMutation, +} from './useSettings'; +export { usePublicSettings } from './usePublicSettings'; +export { useExperimentalSettings } from './useExperimentalSettings'; +export { useUpdateExperimentalSettingsMutation } from './useExperimentalSettingsMutation'; diff --git a/app/react/portainer/settings/queries/queryKeys.ts b/app/react/portainer/settings/queries/queryKeys.ts new file mode 100644 index 000000000..bd9330bd0 --- /dev/null +++ b/app/react/portainer/settings/queries/queryKeys.ts @@ -0,0 +1,5 @@ +export const queryKeys = { + base: () => ['settings'] as const, + public: () => [...queryKeys.base(), 'public'] as const, + experimental: () => [...queryKeys.base(), 'experimental'] as const, +}; diff --git a/app/react/portainer/settings/queries/useExperimentalSettings.ts b/app/react/portainer/settings/queries/useExperimentalSettings.ts new file mode 100644 index 000000000..642df4d2a --- /dev/null +++ b/app/react/portainer/settings/queries/useExperimentalSettings.ts @@ -0,0 +1,39 @@ +import { useQuery } from 'react-query'; + +import axios, { parseAxiosError } from '@/portainer/services/axios'; +import { withError } from '@/react-tools/react-query'; + +import { ExperimentalFeatures } from '../types'; +import { buildUrl } from '../settings.service'; + +import { queryKeys } from './queryKeys'; + +type ExperimentalFeaturesSettings = { + experimentalFeatures: ExperimentalFeatures; +}; + +export function useExperimentalSettings( + select?: (settings: ExperimentalFeaturesSettings) => T, + enabled = true +) { + return useQuery(queryKeys.experimental(), getExperimentalSettings, { + select, + enabled, + staleTime: 50, + ...withError('Unable to retrieve experimental settings'), + }); +} + +async function getExperimentalSettings() { + try { + const { data } = await axios.get( + buildUrl('experimental') + ); + return data; + } catch (e) { + throw parseAxiosError( + e as Error, + 'Unable to retrieve experimental settings' + ); + } +} diff --git a/app/react/portainer/settings/queries/useExperimentalSettingsMutation.ts b/app/react/portainer/settings/queries/useExperimentalSettingsMutation.ts new file mode 100644 index 000000000..d995395a2 --- /dev/null +++ b/app/react/portainer/settings/queries/useExperimentalSettingsMutation.ts @@ -0,0 +1,35 @@ +import { useMutation, useQueryClient } from 'react-query'; + +import axios, { parseAxiosError } from '@/portainer/services/axios'; +import { + mutationOptions, + withError, + withInvalidate, +} from '@/react-tools/react-query'; + +import { ExperimentalFeatures } from '../types'; +import { buildUrl } from '../settings.service'; + +import { queryKeys } from './queryKeys'; + +export function useUpdateExperimentalSettingsMutation() { + const queryClient = useQueryClient(); + + return useMutation( + updateExperimentalSettings, + mutationOptions( + withInvalidate(queryClient, [queryKeys.base()]), + withError('Unable to update experimental settings') + ) + ); +} + +async function updateExperimentalSettings( + settings: Partial +) { + try { + await axios.put(buildUrl('experimental'), settings); + } catch (e) { + throw parseAxiosError(e as Error, 'Unable to update experimental settings'); + } +} diff --git a/app/react/portainer/settings/queries/usePublicSettings.ts b/app/react/portainer/settings/queries/usePublicSettings.ts new file mode 100644 index 000000000..f13f27bd9 --- /dev/null +++ b/app/react/portainer/settings/queries/usePublicSettings.ts @@ -0,0 +1,25 @@ +import { useQuery } from 'react-query'; + +import { withError } from '@/react-tools/react-query'; + +import { getPublicSettings } from '../settings.service'; +import { PublicSettingsResponse } from '../types'; + +import { queryKeys } from './queryKeys'; + +export function usePublicSettings({ + enabled, + select, + onSuccess, +}: { + select?: (settings: PublicSettingsResponse) => T; + enabled?: boolean; + onSuccess?: (data: T) => void; +} = {}) { + return useQuery(queryKeys.public(), getPublicSettings, { + select, + ...withError('Unable to retrieve public settings'), + enabled, + onSuccess, + }); +} diff --git a/app/react/portainer/settings/queries.ts b/app/react/portainer/settings/queries/useSettings.ts similarity index 52% rename from app/react/portainer/settings/queries.ts rename to app/react/portainer/settings/queries/useSettings.ts index fe2076ccf..75315c061 100644 --- a/app/react/portainer/settings/queries.ts +++ b/app/react/portainer/settings/queries/useSettings.ts @@ -1,4 +1,4 @@ -import { useMutation, useQuery, useQueryClient } from 'react-query'; +import { useQuery, useMutation, useQueryClient } from 'react-query'; import { mutationOptions, @@ -7,37 +7,22 @@ import { } from '@/react-tools/react-query'; import { - getSettings, updateSettings, - getPublicSettings, + getSettings, updateDefaultRegistry, -} from './settings.service'; -import { DefaultRegistry, PublicSettingsResponse, Settings } from './types'; +} from '../settings.service'; +import { DefaultRegistry, Settings } from '../types'; -export function usePublicSettings({ - enabled, - select, - onSuccess, -}: { - select?: (settings: PublicSettingsResponse) => T; - enabled?: boolean; - onSuccess?: (data: T) => void; -} = {}) { - return useQuery(['settings', 'public'], getPublicSettings, { - select, - ...withError('Unable to retrieve public settings'), - enabled, - onSuccess, - }); -} +import { queryKeys } from './queryKeys'; export function useSettings( select?: (settings: Settings) => T, - enabled?: boolean + enabled = true ) { - return useQuery(['settings'], getSettings, { + return useQuery(queryKeys.base(), getSettings, { select, enabled, + staleTime: 50, ...withError('Unable to retrieve settings'), }); } @@ -48,7 +33,7 @@ export function useUpdateSettingsMutation() { return useMutation( updateSettings, mutationOptions( - withInvalidate(queryClient, [['settings'], ['cloud']]), + withInvalidate(queryClient, [queryKeys.base(), ['cloud']]), withError('Unable to update settings') ) ); @@ -60,7 +45,7 @@ export function useUpdateDefaultRegistrySettingsMutation() { return useMutation( (payload: Partial) => updateDefaultRegistry(payload), mutationOptions( - withInvalidate(queryClient, [['settings']]), + withInvalidate(queryClient, [queryKeys.base()]), withError('Unable to update default registry settings') ) ); diff --git a/app/react/portainer/settings/settings.service.ts b/app/react/portainer/settings/settings.service.ts index 4255ec173..5915f5df6 100644 --- a/app/react/portainer/settings/settings.service.ts +++ b/app/react/portainer/settings/settings.service.ts @@ -16,6 +16,11 @@ export async function getPublicSettings() { } } +export async function getGlobalDeploymentOptions() { + const publicSettings = await getPublicSettings(); + return publicSettings.GlobalDeploymentOptions; +} + export async function getSettings() { try { const { data } = await axios.get(buildUrl()); @@ -54,7 +59,7 @@ export async function updateDefaultRegistry( } } -function buildUrl(subResource?: string, action?: string) { +export function buildUrl(subResource?: string, action?: string) { let url = 'settings'; if (subResource) { url += `/${subResource}`; diff --git a/app/react/portainer/settings/types.ts b/app/react/portainer/settings/types.ts index e8f186a6c..8193c570f 100644 --- a/app/react/portainer/settings/types.ts +++ b/app/react/portainer/settings/types.ts @@ -93,6 +93,10 @@ export interface DefaultRegistry { Hide: boolean; } +export interface ExperimentalFeatures { + OpenAIIntegration: boolean; +} + export interface Settings { LogoURL: string; CustomLoginBanner: string; @@ -123,6 +127,7 @@ export interface Settings { DisplayDonationHeader: boolean; DisplayExternalContributors: boolean; EnableHostManagementFeatures: boolean; + ExperimentalFeatures?: ExperimentalFeatures; AllowVolumeBrowserForRegularUsers: boolean; AllowBindMountsForRegularUsers: boolean; AllowPrivilegedModeForRegularUsers: boolean;