refactor(settings): migrate view to react [EE-5509] (#9179)

pull/8937/head
Chaim Lev-Ari 2023-07-13 10:46:12 +03:00 committed by GitHub
parent b93624fa1f
commit 0e9902fee9
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
25 changed files with 548 additions and 281 deletions

View File

@ -364,8 +364,7 @@ angular
url: '/settings', url: '/settings',
views: { views: {
'content@': { 'content@': {
templateUrl: './views/settings/settings.html', component: 'settingsView',
controller: 'SettingsController',
}, },
}, },
}; };

View File

@ -11,6 +11,7 @@ import { withI18nSuspense } from '@/react-tools/withI18nSuspense';
import { EdgeAutoCreateScriptView } from '@/react/portainer/environments/EdgeAutoCreateScriptView'; import { EdgeAutoCreateScriptView } from '@/react/portainer/environments/EdgeAutoCreateScriptView';
import { ListView as EnvironmentsListView } from '@/react/portainer/environments/ListView'; import { ListView as EnvironmentsListView } from '@/react/portainer/environments/ListView';
import { BackupSettingsPanel } from '@/react/portainer/settings/SettingsView/BackupSettingsView/BackupSettingsPanel'; import { BackupSettingsPanel } from '@/react/portainer/settings/SettingsView/BackupSettingsView/BackupSettingsPanel';
import { SettingsView } from '@/react/portainer/settings/SettingsView/SettingsView';
import { wizardModule } from './wizard'; import { wizardModule } from './wizard';
import { teamsModule } from './teams'; import { teamsModule } from './teams';
@ -54,4 +55,8 @@ export const viewsModule = angular
.component( .component(
'backupSettingsPanel', 'backupSettingsPanel',
r2a(withUIRouter(withReactQuery(withCurrentUser(BackupSettingsPanel))), []) r2a(withUIRouter(withReactQuery(withCurrentUser(BackupSettingsPanel))), [])
)
.component(
'settingsView',
r2a(withUIRouter(withReactQuery(withCurrentUser(SettingsView))), [])
).name; ).name;

View File

@ -2,6 +2,9 @@ import { Environment } from '@/react/portainer/environments/types';
export interface StateManager { export interface StateManager {
updateEndpointState(endpoint: Environment): Promise<void>; updateEndpointState(endpoint: Environment): Promise<void>;
updateLogo(logo: string): void;
updateSnapshotInterval(interval: string): void;
updateEnableTelemetry(enable: boolean): void;
} }
export interface IAuthenticationService { export interface IAuthenticationService {

View File

@ -1,14 +0,0 @@
<page-header title="'Settings'" breadcrumbs="['Settings']"> </page-header>
<application-settings-panel on-success="(handleSuccess)"></application-settings-panel>
<kube-settings-panel></kube-settings-panel>
<helm-cert-panel></helm-cert-panel>
<ssl-settings-panel></ssl-settings-panel>
<hidden-containers-panel></hidden-containers-panel>
<!-- backup -->
<backup-settings-panel></backup-settings-panel>

View File

@ -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);
}
}
}

View File

@ -47,8 +47,6 @@ export function ApplicationSettingsPanel({
}; };
return ( return (
<div className="row">
<div className="col-sm-12">
<Widget> <Widget>
<Widget.Title icon={SettingsIcon} title="Application settings" /> <Widget.Title icon={SettingsIcon} title="Application settings" />
<Widget.Body> <Widget.Body>
@ -62,8 +60,6 @@ export function ApplicationSettingsPanel({
</Formik> </Formik>
</Widget.Body> </Widget.Body>
</Widget> </Widget>
</div>
</div>
); );
function handleSubmit(values: Values) { function handleSubmit(values: Values) {

View File

@ -13,16 +13,14 @@ export function BackupSettingsPanel() {
const [backupType, setBackupType] = useState(options[0].value); const [backupType, setBackupType] = useState(options[0].value);
return ( return (
<div className="row">
<div className="col-sm-12">
<Widget> <Widget>
<WidgetTitle icon={Download} title="Backup up Portainer" /> <WidgetTitle icon={Download} title="Backup up Portainer" />
<WidgetBody> <WidgetBody>
<div className="form-horizontal"> <div className="form-horizontal">
<FormSection title="Backup configuration"> <FormSection title="Backup configuration">
<div className="form-group col-sm-12 text-muted small"> <div className="form-group col-sm-12 text-muted small">
This will back up your Portainer server configuration and does This will back up your Portainer server configuration and does not
not include containers. include containers.
</div> </div>
<BoxSelector <BoxSelector
slim slim
@ -41,7 +39,5 @@ export function BackupSettingsPanel() {
</div> </div>
</WidgetBody> </WidgetBody>
</Widget> </Widget>
</div>
</div>
); );
} }

View File

@ -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<boolean>(fieldKey);
return (
<FormControl
inputId="experimental_openAI"
label="Enable OpenAI integration"
size="medium"
errors={meta.error}
>
<Switch
id="experimental_openAI"
name={fieldKey}
className="space-right"
checked={inputProps.value}
onChange={handleChange}
/>
</FormControl>
);
function handleChange(enable: boolean) {
helpers.setValue(enable);
}
}

View File

@ -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 (
<Widget>
<WidgetTitle icon={FlaskConical} title="Experimental features" />
<WidgetBody>
<ExperimentalFeaturesSettingsForm
settings={settings.experimentalFeatures}
/>
</WidgetBody>
</Widget>
);
}

View File

@ -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 (
<Formik<FormValues>
initialValues={initialValues}
onSubmit={handleSubmit}
validationSchema={validation}
validateOnMount
enableReinitialize
>
{({ isValid, dirty }) => (
<Form className="form-horizontal">
<TextTip color="blue" icon={FlaskConical}>
Experimental features may be discontinued without notice.
</TextTip>
<br />
<br />
<div className="form-group col-sm-12 text-muted small">
In Portainer releases, we may introduce features that we&apos;re
experimenting with. These will be items in the early phases of
development with limited testing.
<br />
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.
</div>
<EnableOpenAIIntegrationSwitch />
<div className="form-group">
<div className="col-sm-12">
<LoadingButton
loadingText="Saving settings..."
isLoading={mutation.isLoading}
disabled={!isValid || !dirty}
className="!ml-0"
data-cy="settings-experimentalButton"
>
Save experimental settings
</LoadingButton>
</div>
</div>
</Form>
)}
</Formik>
);
}

View File

@ -0,0 +1 @@
export { ExperimentalFeatures } from './ExperimentalFeatures';

View File

@ -30,8 +30,6 @@ export function HelmCertPanel() {
}; };
return ( return (
<div className="row">
<div className="col-sm-12">
<BEOverlay featureId={FeatureId.CA_FILE}> <BEOverlay featureId={FeatureId.CA_FILE}>
<Widget> <Widget>
<Widget.Title <Widget.Title
@ -50,8 +48,6 @@ export function HelmCertPanel() {
</Widget.Body> </Widget.Body>
</Widget> </Widget>
</BEOverlay> </BEOverlay>
</div>
</div>
); );
function handleSubmit({ clientCertFile }: FormValues) { function handleSubmit({ clientCertFile }: FormValues) {

View File

@ -21,23 +21,19 @@ export function HiddenContainersPanel() {
const labels = settingsQuery.data; const labels = settingsQuery.data;
return ( return (
<div className="row">
<div className="col-sm-12">
<Widget> <Widget>
<Widget.Title icon={Box} title="Hidden containers" /> <Widget.Title icon={Box} title="Hidden containers" />
<Widget.Body> <Widget.Body>
<div className="mb-3"> <div className="mb-3">
<TextTip color="blue"> <TextTip color="blue">
You can hide containers with specific labels from Portainer UI. You can hide containers with specific labels from Portainer UI. You
You need to specify the label name and value. need to specify the label name and value.
</TextTip> </TextTip>
</div> </div>
<AddLabelForm <AddLabelForm
isLoading={mutation.isLoading} isLoading={mutation.isLoading}
onSubmit={(name, value) => onSubmit={(name, value) => handleSubmit([...labels, { name, value }])}
handleSubmit([...labels, { name, value }])
}
/> />
<HiddenContainersTable <HiddenContainersTable
@ -49,8 +45,6 @@ export function HiddenContainersPanel() {
/> />
</Widget.Body> </Widget.Body>
</Widget> </Widget>
</div>
</div>
); );
function handleSubmit(labels: Pair[]) { function handleSubmit(labels: Pair[]) {

View File

@ -40,8 +40,6 @@ export function KubeSettingsPanel() {
}; };
return ( return (
<div className="row">
<div className="col-sm-12">
<Widget> <Widget>
<Widget.Title icon={kubeIcon} title="Kubernetes settings" /> <Widget.Title icon={kubeIcon} title="Kubernetes settings" />
<Widget.Body> <Widget.Body>
@ -73,8 +71,6 @@ export function KubeSettingsPanel() {
</Formik> </Formik>
</Widget.Body> </Widget.Body>
</Widget> </Widget>
</div>
</div>
); );
function handleSubmit(values: FormValues) { function handleSubmit(values: FormValues) {

View File

@ -43,8 +43,6 @@ function SSLSettingsPanel() {
}; };
return ( return (
<div className="row">
<div className="col-sm-12">
<Widget> <Widget>
<Widget.Title icon={Key} title="SSL certificate" /> <Widget.Title icon={Key} title="SSL certificate" />
<Widget.Body> <Widget.Body>
@ -59,9 +57,9 @@ function SSLSettingsPanel() {
<div className="form-group"> <div className="form-group">
<div className="col-sm-12"> <div className="col-sm-12">
<TextTip color="orange"> <TextTip color="orange">
Forcing HTTPs only will cause Portainer to stop Forcing HTTPs only will cause Portainer to stop listening on
listening on the HTTP port. Any edge agent environment the HTTP port. Any edge agent environment that is using HTTP
that is using HTTP will no longer be available. will no longer be available.
</TextTip> </TextTip>
</div> </div>
</div> </div>
@ -81,8 +79,8 @@ function SSLSettingsPanel() {
<div className="form-group"> <div className="form-group">
<div className="col-sm-12"> <div className="col-sm-12">
<TextTip color="blue"> <TextTip color="blue">
Provide a new SSL Certificate to replace the existing Provide a new SSL Certificate to replace the existing one
one that is used for HTTPS connections. that is used for HTTPS connections.
</TextTip> </TextTip>
</div> </div>
</div> </div>
@ -134,8 +132,6 @@ function SSLSettingsPanel() {
</Formik> </Formik>
</Widget.Body> </Widget.Body>
</Widget> </Widget>
</div>
</div>
); );
function handleSubmit({ certFile, forceHTTPS, keyFile }: FormValues) { function handleSubmit({ certFile, forceHTTPS, keyFile }: FormValues) {

View File

@ -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 (
<>
<PageHeader title="Settings" breadcrumbs="Settings" />
<div className="mx-4 space-y-4">
<ApplicationSettingsPanel onSuccess={handleSuccess} />
<KubeSettingsPanel />
<HelmCertPanel />
<SSLSettingsPanelWrapper />
{isBE && <ExperimentalFeatures />}
<HiddenContainersPanel />
<BackupSettingsPanel />
</div>
</>
);
}
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);
}
);
}

View File

@ -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';

View File

@ -0,0 +1,5 @@
export const queryKeys = {
base: () => ['settings'] as const,
public: () => [...queryKeys.base(), 'public'] as const,
experimental: () => [...queryKeys.base(), 'experimental'] as const,
};

View File

@ -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<T = ExperimentalFeaturesSettings>(
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<ExperimentalFeaturesSettings>(
buildUrl('experimental')
);
return data;
} catch (e) {
throw parseAxiosError(
e as Error,
'Unable to retrieve experimental settings'
);
}
}

View File

@ -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<ExperimentalFeatures>
) {
try {
await axios.put(buildUrl('experimental'), settings);
} catch (e) {
throw parseAxiosError(e as Error, 'Unable to update experimental settings');
}
}

View File

@ -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<T = PublicSettingsResponse>({
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,
});
}

View File

@ -1,4 +1,4 @@
import { useMutation, useQuery, useQueryClient } from 'react-query'; import { useQuery, useMutation, useQueryClient } from 'react-query';
import { import {
mutationOptions, mutationOptions,
@ -7,37 +7,22 @@ import {
} from '@/react-tools/react-query'; } from '@/react-tools/react-query';
import { import {
getSettings,
updateSettings, updateSettings,
getPublicSettings, getSettings,
updateDefaultRegistry, updateDefaultRegistry,
} from './settings.service'; } from '../settings.service';
import { DefaultRegistry, PublicSettingsResponse, Settings } from './types'; import { DefaultRegistry, Settings } from '../types';
export function usePublicSettings<T = PublicSettingsResponse>({ import { queryKeys } from './queryKeys';
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,
});
}
export function useSettings<T = Settings>( export function useSettings<T = Settings>(
select?: (settings: Settings) => T, select?: (settings: Settings) => T,
enabled?: boolean enabled = true
) { ) {
return useQuery(['settings'], getSettings, { return useQuery(queryKeys.base(), getSettings, {
select, select,
enabled, enabled,
staleTime: 50,
...withError('Unable to retrieve settings'), ...withError('Unable to retrieve settings'),
}); });
} }
@ -48,7 +33,7 @@ export function useUpdateSettingsMutation() {
return useMutation( return useMutation(
updateSettings, updateSettings,
mutationOptions( mutationOptions(
withInvalidate(queryClient, [['settings'], ['cloud']]), withInvalidate(queryClient, [queryKeys.base(), ['cloud']]),
withError('Unable to update settings') withError('Unable to update settings')
) )
); );
@ -60,7 +45,7 @@ export function useUpdateDefaultRegistrySettingsMutation() {
return useMutation( return useMutation(
(payload: Partial<DefaultRegistry>) => updateDefaultRegistry(payload), (payload: Partial<DefaultRegistry>) => updateDefaultRegistry(payload),
mutationOptions( mutationOptions(
withInvalidate(queryClient, [['settings']]), withInvalidate(queryClient, [queryKeys.base()]),
withError('Unable to update default registry settings') withError('Unable to update default registry settings')
) )
); );

View File

@ -16,6 +16,11 @@ export async function getPublicSettings() {
} }
} }
export async function getGlobalDeploymentOptions() {
const publicSettings = await getPublicSettings();
return publicSettings.GlobalDeploymentOptions;
}
export async function getSettings() { export async function getSettings() {
try { try {
const { data } = await axios.get<Settings>(buildUrl()); const { data } = await axios.get<Settings>(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'; let url = 'settings';
if (subResource) { if (subResource) {
url += `/${subResource}`; url += `/${subResource}`;

View File

@ -93,6 +93,10 @@ export interface DefaultRegistry {
Hide: boolean; Hide: boolean;
} }
export interface ExperimentalFeatures {
OpenAIIntegration: boolean;
}
export interface Settings { export interface Settings {
LogoURL: string; LogoURL: string;
CustomLoginBanner: string; CustomLoginBanner: string;
@ -123,6 +127,7 @@ export interface Settings {
DisplayDonationHeader: boolean; DisplayDonationHeader: boolean;
DisplayExternalContributors: boolean; DisplayExternalContributors: boolean;
EnableHostManagementFeatures: boolean; EnableHostManagementFeatures: boolean;
ExperimentalFeatures?: ExperimentalFeatures;
AllowVolumeBrowserForRegularUsers: boolean; AllowVolumeBrowserForRegularUsers: boolean;
AllowBindMountsForRegularUsers: boolean; AllowBindMountsForRegularUsers: boolean;
AllowPrivilegedModeForRegularUsers: boolean; AllowPrivilegedModeForRegularUsers: boolean;