mirror of https://github.com/portainer/portainer
refactor(edge/stacks): migrate create view to react [EE-2223] (#11575)
parent
f22aed34b5
commit
8a81d95253
|
@ -69,7 +69,7 @@ angular
|
||||||
url: '/new?templateId&templateType',
|
url: '/new?templateId&templateType',
|
||||||
views: {
|
views: {
|
||||||
'content@': {
|
'content@': {
|
||||||
component: 'createEdgeStackView',
|
component: 'edgeStacksCreateView',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
data: {
|
data: {
|
||||||
|
|
|
@ -13,7 +13,6 @@ import { withUIRouter } from '@/react-tools/withUIRouter';
|
||||||
import { EdgeGroupAssociationTable } from '@/react/edge/components/EdgeGroupAssociationTable';
|
import { EdgeGroupAssociationTable } from '@/react/edge/components/EdgeGroupAssociationTable';
|
||||||
import { AssociatedEdgeEnvironmentsSelector } from '@/react/edge/components/AssociatedEdgeEnvironmentsSelector';
|
import { AssociatedEdgeEnvironmentsSelector } from '@/react/edge/components/AssociatedEdgeEnvironmentsSelector';
|
||||||
import { EnvironmentsDatatable } from '@/react/edge/edge-stacks/ItemView/EnvironmentsDatatable';
|
import { EnvironmentsDatatable } from '@/react/edge/edge-stacks/ItemView/EnvironmentsDatatable';
|
||||||
import { TemplateFieldset } from '@/react/edge/edge-stacks/CreateView/TemplateFieldset/TemplateFieldset';
|
|
||||||
|
|
||||||
import { edgeJobsModule } from './edge-jobs';
|
import { edgeJobsModule } from './edge-jobs';
|
||||||
|
|
||||||
|
@ -102,10 +101,6 @@ const ngModule = angular
|
||||||
'onChange',
|
'onChange',
|
||||||
'value',
|
'value',
|
||||||
])
|
])
|
||||||
)
|
|
||||||
.component(
|
|
||||||
'edgeStackCreateTemplateFieldset',
|
|
||||||
r2a(withReactQuery(TemplateFieldset), ['setValues', 'values', 'errors'])
|
|
||||||
);
|
);
|
||||||
|
|
||||||
export const componentsModule = ngModule.name;
|
export const componentsModule = ngModule.name;
|
||||||
|
|
|
@ -0,0 +1,13 @@
|
||||||
|
import angular from 'angular';
|
||||||
|
|
||||||
|
import { r2a } from '@/react-tools/react2angular';
|
||||||
|
import { withCurrentUser } from '@/react-tools/withCurrentUser';
|
||||||
|
import { withUIRouter } from '@/react-tools/withUIRouter';
|
||||||
|
import { CreateView } from '@/react/edge/edge-stacks/CreateView/CreateView';
|
||||||
|
|
||||||
|
export const stacksModule = angular
|
||||||
|
.module('portainer.edge.react.views.stacks', [])
|
||||||
|
.component(
|
||||||
|
'edgeStacksCreateView',
|
||||||
|
r2a(withCurrentUser(withUIRouter(CreateView)), [])
|
||||||
|
).name;
|
|
@ -10,9 +10,14 @@ import { ListView as EdgeGroupsListView } from '@/react/edge/edge-groups/ListVie
|
||||||
|
|
||||||
import { templatesModule } from './templates';
|
import { templatesModule } from './templates';
|
||||||
import { jobsModule } from './jobs';
|
import { jobsModule } from './jobs';
|
||||||
|
import { stacksModule } from './edge-stacks';
|
||||||
|
|
||||||
export const viewsModule = angular
|
export const viewsModule = angular
|
||||||
.module('portainer.edge.react.views', [templatesModule, jobsModule])
|
.module('portainer.edge.react.views', [
|
||||||
|
templatesModule,
|
||||||
|
jobsModule,
|
||||||
|
stacksModule,
|
||||||
|
])
|
||||||
.component(
|
.component(
|
||||||
'waitingRoomView',
|
'waitingRoomView',
|
||||||
r2a(withUIRouter(withReactQuery(withCurrentUser(WaitingRoomView))), [])
|
r2a(withUIRouter(withReactQuery(withCurrentUser(WaitingRoomView))), [])
|
||||||
|
|
|
@ -1,432 +0,0 @@
|
||||||
import { DeploymentType, EditorType } from '@/react/edge/edge-stacks/types';
|
|
||||||
import { getValidEditorTypes } from '@/react/edge/edge-stacks/utils';
|
|
||||||
import { STACK_NAME_VALIDATION_REGEX } from '@/react/constants';
|
|
||||||
import { confirmWebEditorDiscard } from '@@/modals/confirm';
|
|
||||||
import { baseEdgeStackWebhookUrl } from '@/portainer/helpers/webhookHelper';
|
|
||||||
import { EnvironmentType } from '@/react/portainer/environments/types';
|
|
||||||
import { isBE } from '@/react/portainer/feature-flags/feature-flags.service';
|
|
||||||
import { getCustomTemplate } from '@/react/portainer/templates/custom-templates/queries/useCustomTemplate';
|
|
||||||
import { notifyError } from '@/portainer/services/notifications';
|
|
||||||
import { getCustomTemplateFile } from '@/react/portainer/templates/custom-templates/queries/useCustomTemplateFile';
|
|
||||||
import { toGitFormModel } from '@/react/portainer/gitops/types';
|
|
||||||
import { StackType } from '@/react/common/stacks/types';
|
|
||||||
import { applySetStateAction } from '@/react-tools/apply-set-state-action';
|
|
||||||
import { getVariablesFieldDefaultValues } from '@/react/portainer/custom-templates/components/CustomTemplatesVariablesField';
|
|
||||||
import { renderTemplate } from '@/react/portainer/custom-templates/components/utils';
|
|
||||||
import { getInitialTemplateValues } from '@/react/edge/edge-stacks/CreateView/TemplateFieldset/TemplateFieldset';
|
|
||||||
import { getAppTemplates } from '@/react/portainer/templates/app-templates/queries/useAppTemplates';
|
|
||||||
import { fetchFilePreview } from '@/react/portainer/templates/app-templates/queries/useFetchTemplateFile';
|
|
||||||
import { TemplateViewModel } from '@/react/portainer/templates/app-templates/view-model';
|
|
||||||
import { getDefaultValues as getAppVariablesDefaultValues } from '@/react/portainer/templates/app-templates/DeployFormWidget/EnvVarsFieldset';
|
|
||||||
|
|
||||||
export default class CreateEdgeStackViewController {
|
|
||||||
/* @ngInject */
|
|
||||||
constructor($state, $window, EdgeStackService, EdgeGroupService, Notifications, FormHelper, $async, $scope) {
|
|
||||||
Object.assign(this, { $state, $window, EdgeStackService, EdgeGroupService, Notifications, FormHelper, $async, $scope });
|
|
||||||
|
|
||||||
this.formValues = {
|
|
||||||
Name: '',
|
|
||||||
StackFileContent: '',
|
|
||||||
StackFile: null,
|
|
||||||
RepositoryURL: '',
|
|
||||||
RepositoryReferenceName: '',
|
|
||||||
RepositoryAuthentication: false,
|
|
||||||
RepositoryUsername: '',
|
|
||||||
RepositoryPassword: '',
|
|
||||||
ComposeFilePathInRepository: '',
|
|
||||||
Groups: [],
|
|
||||||
DeploymentType: 0,
|
|
||||||
UseManifestNamespaces: false,
|
|
||||||
TLSSkipVerify: false,
|
|
||||||
envVars: [],
|
|
||||||
};
|
|
||||||
|
|
||||||
this.EditorType = EditorType;
|
|
||||||
this.EnvironmentType = EnvironmentType;
|
|
||||||
this.isBE = isBE;
|
|
||||||
|
|
||||||
this.state = {
|
|
||||||
Method: 'editor',
|
|
||||||
formValidationError: '',
|
|
||||||
actionInProgress: false,
|
|
||||||
StackType: null,
|
|
||||||
isEditorDirty: false,
|
|
||||||
hasKubeEndpoint: false,
|
|
||||||
endpointTypes: [],
|
|
||||||
baseWebhookUrl: baseEdgeStackWebhookUrl(),
|
|
||||||
isEdit: false,
|
|
||||||
templateValues: getInitialTemplateValues(),
|
|
||||||
};
|
|
||||||
|
|
||||||
this.edgeGroups = null;
|
|
||||||
|
|
||||||
$scope.STACK_NAME_VALIDATION_REGEX = STACK_NAME_VALIDATION_REGEX;
|
|
||||||
|
|
||||||
this.createStack = this.createStack.bind(this);
|
|
||||||
this.validateForm = this.validateForm.bind(this);
|
|
||||||
this.createStackByMethod = this.createStackByMethod.bind(this);
|
|
||||||
this.createStackFromFileContent = this.createStackFromFileContent.bind(this);
|
|
||||||
this.createStackFromFileUpload = this.createStackFromFileUpload.bind(this);
|
|
||||||
this.createStackFromGitRepository = this.createStackFromGitRepository.bind(this);
|
|
||||||
this.onChangeGroups = this.onChangeGroups.bind(this);
|
|
||||||
this.hasType = this.hasType.bind(this);
|
|
||||||
this.onChangeDeploymentType = this.onChangeDeploymentType.bind(this);
|
|
||||||
this.onEnvVarChange = this.onEnvVarChange.bind(this);
|
|
||||||
this.setTemplateValues = this.setTemplateValues.bind(this);
|
|
||||||
this.onChangeTemplate = this.onChangeTemplate.bind(this);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param {import('react').SetStateAction<import('@/react/edge/edge-stacks/CreateView/TemplateFieldset/types').Values>} templateAction
|
|
||||||
*/
|
|
||||||
setTemplateValues(templateAction) {
|
|
||||||
return this.$async(async () => {
|
|
||||||
const newTemplateValues = applySetStateAction(templateAction, this.state.templateValues);
|
|
||||||
const oldTemplateId = this.state.templateValues.template && this.state.templateValues.template.Id;
|
|
||||||
const newTemplateId = newTemplateValues.template && newTemplateValues.template.Id;
|
|
||||||
this.state.templateValues = newTemplateValues;
|
|
||||||
if (newTemplateId !== oldTemplateId) {
|
|
||||||
await this.onChangeTemplate(newTemplateValues.type, newTemplateValues.template);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (newTemplateValues.type === 'custom') {
|
|
||||||
const definitions = this.state.templateValues.template.Variables;
|
|
||||||
const newFile = renderTemplate(this.state.templateValues.file, this.state.templateValues.variables, definitions);
|
|
||||||
|
|
||||||
this.formValues.StackFileContent = newFile;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
onChangeTemplate(type, template) {
|
|
||||||
return this.$async(async () => {
|
|
||||||
if (!template) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (type === 'custom') {
|
|
||||||
this.formValues = {
|
|
||||||
...this.formValues,
|
|
||||||
DeploymentType: template.Type === StackType.Kubernetes ? DeploymentType.Kubernetes : DeploymentType.Compose,
|
|
||||||
...toGitFormModel(template.GitConfig),
|
|
||||||
...(template.EdgeSettings
|
|
||||||
? {
|
|
||||||
PrePullImage: template.EdgeSettings.PrePullImage || false,
|
|
||||||
RetryDeploy: template.EdgeSettings.RetryDeploy || false,
|
|
||||||
PrivateRegistryId: template.EdgeSettings.PrivateRegistryId || null,
|
|
||||||
...template.EdgeSettings.RelativePathSettings,
|
|
||||||
}
|
|
||||||
: {}),
|
|
||||||
};
|
|
||||||
|
|
||||||
const fileContent = await getCustomTemplateFile({ id: template.Id, git: !!template.GitConfig });
|
|
||||||
this.state.templateValues.file = fileContent;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (type === 'app') {
|
|
||||||
this.formValues.StackFileContent = '';
|
|
||||||
try {
|
|
||||||
const fileContent = await fetchFilePreview(template.Id);
|
|
||||||
this.formValues.StackFileContent = fileContent;
|
|
||||||
} catch (err) {
|
|
||||||
this.Notifications.error('Failure', err, 'Unable to retrieve Template');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
onEnvVarChange(envVars) {
|
|
||||||
return this.$scope.$evalAsync(() => {
|
|
||||||
this.formValues.envVars = envVars;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
buildAnalyticsProperties() {
|
|
||||||
const format = 'compose';
|
|
||||||
const metadata = { type: methodLabel(this.state.Method), format };
|
|
||||||
|
|
||||||
if (metadata.type === 'template') {
|
|
||||||
metadata.templateName = this.state.selectedTemplate && this.state.selectedTemplate.title;
|
|
||||||
}
|
|
||||||
|
|
||||||
return { metadata };
|
|
||||||
|
|
||||||
function methodLabel(method) {
|
|
||||||
switch (method) {
|
|
||||||
case 'editor':
|
|
||||||
return 'web-editor';
|
|
||||||
case 'repository':
|
|
||||||
return 'git';
|
|
||||||
case 'upload':
|
|
||||||
return 'file-upload';
|
|
||||||
case 'template':
|
|
||||||
return 'template';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
uiCanExit() {
|
|
||||||
if (this.state.Method === 'editor' && this.formValues.StackFileContent && this.state.isEditorDirty) {
|
|
||||||
return confirmWebEditorDiscard();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
*
|
|
||||||
* @param {'app' | 'custom'} templateType
|
|
||||||
* @param {number} templateId
|
|
||||||
* @returns {Promise<void>}
|
|
||||||
*/
|
|
||||||
async preSelectTemplate(templateType, templateId) {
|
|
||||||
return this.$async(async () => {
|
|
||||||
try {
|
|
||||||
this.state.Method = 'template';
|
|
||||||
const template = await getTemplate(templateType, templateId);
|
|
||||||
if (!template) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.setTemplateValues({
|
|
||||||
template,
|
|
||||||
type: templateType,
|
|
||||||
envVars: templateType === 'app' ? getAppVariablesDefaultValues(template.Env) : {},
|
|
||||||
variables: templateType === 'custom' ? getVariablesFieldDefaultValues(template.Variables) : [],
|
|
||||||
});
|
|
||||||
} catch (e) {
|
|
||||||
notifyError('Failed loading template', e);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async $onInit() {
|
|
||||||
try {
|
|
||||||
this.edgeGroups = await this.EdgeGroupService.groups();
|
|
||||||
} catch (err) {
|
|
||||||
this.Notifications.error('Failure', err, 'Unable to retrieve Edge groups');
|
|
||||||
}
|
|
||||||
|
|
||||||
const templateId = parseInt(this.$state.params.templateId, 10);
|
|
||||||
const templateType = this.$state.params.templateType;
|
|
||||||
if (templateType && templateId && !Number.isNaN(templateId)) {
|
|
||||||
this.preSelectTemplate(templateType, templateId);
|
|
||||||
}
|
|
||||||
|
|
||||||
this.$window.onbeforeunload = () => {
|
|
||||||
if (this.state.Method === 'editor' && this.formValues.StackFileContent && this.state.isEditorDirty) {
|
|
||||||
return '';
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
$onDestroy() {
|
|
||||||
this.state.isEditorDirty = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
createStack() {
|
|
||||||
return this.$async(async () => {
|
|
||||||
const name = this.formValues.Name;
|
|
||||||
|
|
||||||
if (!this.validateTemplate()) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
let envVars = this.formValues.envVars;
|
|
||||||
if (this.state.Method === 'template' && this.state.templateValues.type === 'app') {
|
|
||||||
envVars = [...envVars, ...Object.entries(this.state.templateValues.envVars).map(([key, value]) => ({ name: key, value }))];
|
|
||||||
}
|
|
||||||
|
|
||||||
const method = getMethod(this.state.Method, this.state.templateValues.template);
|
|
||||||
|
|
||||||
if (!this.validateForm(method)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.state.actionInProgress = true;
|
|
||||||
try {
|
|
||||||
await this.createStackByMethod(name, method, envVars);
|
|
||||||
|
|
||||||
this.Notifications.success('Success', 'Stack successfully deployed');
|
|
||||||
this.state.isEditorDirty = false;
|
|
||||||
this.$state.go('edge.stacks');
|
|
||||||
} catch (err) {
|
|
||||||
this.Notifications.error('Deployment error', err, 'Unable to deploy stack');
|
|
||||||
} finally {
|
|
||||||
this.state.actionInProgress = false;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
onChangeGroups(groups) {
|
|
||||||
return this.$scope.$evalAsync(() => {
|
|
||||||
this.formValues.Groups = groups;
|
|
||||||
|
|
||||||
this.checkIfEndpointTypes(groups);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
checkIfEndpointTypes(groups) {
|
|
||||||
return this.$scope.$evalAsync(() => {
|
|
||||||
const edgeGroups = groups.map((id) => this.edgeGroups.find((e) => e.Id === id));
|
|
||||||
this.state.endpointTypes = edgeGroups.flatMap((group) => group.EndpointTypes);
|
|
||||||
this.selectValidDeploymentType();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
selectValidDeploymentType() {
|
|
||||||
const validTypes = getValidEditorTypes(this.state.endpointTypes);
|
|
||||||
|
|
||||||
if (!validTypes.includes(this.formValues.DeploymentType)) {
|
|
||||||
this.onChangeDeploymentType(validTypes[0]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
hasType(envType) {
|
|
||||||
return this.state.endpointTypes.includes(envType);
|
|
||||||
}
|
|
||||||
|
|
||||||
validateForm(method) {
|
|
||||||
this.state.formValidationError = '';
|
|
||||||
|
|
||||||
if (method === 'editor' && this.formValues.StackFileContent === '') {
|
|
||||||
this.state.formValidationError = 'Stack file content must not be empty';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
createStackByMethod(name, method, envVars) {
|
|
||||||
switch (method) {
|
|
||||||
case 'editor':
|
|
||||||
return this.createStackFromFileContent(name, envVars);
|
|
||||||
case 'upload':
|
|
||||||
return this.createStackFromFileUpload(name, envVars);
|
|
||||||
case 'repository':
|
|
||||||
return this.createStackFromGitRepository(name, envVars);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
createStackFromFileContent(name, envVars) {
|
|
||||||
const { StackFileContent, Groups, DeploymentType, UseManifestNamespaces } = this.formValues;
|
|
||||||
|
|
||||||
return this.EdgeStackService.createStackFromFileContent({
|
|
||||||
name,
|
|
||||||
StackFileContent,
|
|
||||||
EdgeGroups: Groups,
|
|
||||||
DeploymentType,
|
|
||||||
UseManifestNamespaces,
|
|
||||||
envVars,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
createStackFromFileUpload(name, envVars) {
|
|
||||||
const { StackFile, Groups, DeploymentType, UseManifestNamespaces } = this.formValues;
|
|
||||||
|
|
||||||
return this.EdgeStackService.createStackFromFileUpload(
|
|
||||||
{
|
|
||||||
Name: name,
|
|
||||||
EdgeGroups: Groups,
|
|
||||||
DeploymentType,
|
|
||||||
UseManifestNamespaces,
|
|
||||||
envVars,
|
|
||||||
},
|
|
||||||
StackFile
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
async createStackFromGitRepository(name, envVars) {
|
|
||||||
const { Groups, DeploymentType, UseManifestNamespaces } = this.formValues;
|
|
||||||
|
|
||||||
const repositoryOptions = {
|
|
||||||
RepositoryURL: this.formValues.RepositoryURL,
|
|
||||||
RepositoryReferenceName: this.formValues.RepositoryReferenceName,
|
|
||||||
FilePathInRepository: this.formValues.ComposeFilePathInRepository,
|
|
||||||
RepositoryAuthentication: this.formValues.RepositoryAuthentication,
|
|
||||||
RepositoryUsername: this.formValues.RepositoryUsername,
|
|
||||||
RepositoryPassword: this.formValues.RepositoryPassword,
|
|
||||||
TLSSkipVerify: this.formValues.TLSSkipVerify,
|
|
||||||
CreatedFromCustomTemplateID: this.state.templateValues.template && this.state.templateValues.template.Id,
|
|
||||||
};
|
|
||||||
return this.EdgeStackService.createStackFromGitRepository(
|
|
||||||
{
|
|
||||||
name,
|
|
||||||
EdgeGroups: Groups,
|
|
||||||
DeploymentType,
|
|
||||||
UseManifestNamespaces,
|
|
||||||
envVars,
|
|
||||||
},
|
|
||||||
repositoryOptions
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
onChangeDeploymentType(deploymentType) {
|
|
||||||
return this.$scope.$evalAsync(() => {
|
|
||||||
this.formValues.DeploymentType = deploymentType;
|
|
||||||
this.state.Method = 'editor';
|
|
||||||
this.formValues.StackFileContent = '';
|
|
||||||
this.state.templateValues = getInitialTemplateValues();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
validateTemplate() {
|
|
||||||
if (this.state.Method === 'template' && this.state.templateValues.type === 'app') {
|
|
||||||
return Object.entries(this.state.templateValues.envVars).every(([, value]) => !!value);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this.state.Method === 'template' && this.state.templateValues.type === 'custom') {
|
|
||||||
return Object.entries(this.state.templateValues.variables).every(([, v]) => {
|
|
||||||
return !!v.value;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
formIsInvalid() {
|
|
||||||
return (
|
|
||||||
this.form.$invalid ||
|
|
||||||
!this.formValues.Groups.length ||
|
|
||||||
(['template', 'editor'].includes(this.state.Method) && !this.formValues.StackFileContent) ||
|
|
||||||
('upload' === this.state.Method && !this.formValues.StackFile) ||
|
|
||||||
!this.validateTemplate()
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
*
|
|
||||||
* @param {'template'|'repository' | 'editor' | 'upload'} method
|
|
||||||
* @param {import('@/react/portainer/templates/custom-templates/types').CustomTemplate | undefined} template
|
|
||||||
* @returns 'repository' | 'editor' | 'upload'
|
|
||||||
*/
|
|
||||||
function getMethod(method, template) {
|
|
||||||
if (method !== 'template') {
|
|
||||||
return method;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (template && template.GitConfig) {
|
|
||||||
return 'repository';
|
|
||||||
}
|
|
||||||
return 'editor';
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
*
|
|
||||||
* @param {'app' | 'custom'} templateType
|
|
||||||
* @param {number} templateId
|
|
||||||
* @returns {Promise<import('@/react/portainer/templates/app-templates/view-model').TemplateViewModel | import('@/react/portainer/templates/custom-templates/types').CustomTemplate | undefined>}
|
|
||||||
*/
|
|
||||||
async function getTemplate(templateType, templateId) {
|
|
||||||
if (!['app', 'custom'].includes(templateType)) {
|
|
||||||
notifyError('Invalid template type', `Invalid template type: ${templateType}`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (templateType === 'app') {
|
|
||||||
const templatesResponse = await getAppTemplates();
|
|
||||||
const template = templatesResponse.templates.find((t) => t.id === templateId);
|
|
||||||
return new TemplateViewModel(template, templatesResponse.version);
|
|
||||||
}
|
|
||||||
|
|
||||||
const template = await getCustomTemplate(templateId);
|
|
||||||
return template;
|
|
||||||
}
|
|
|
@ -1,101 +0,0 @@
|
||||||
<page-header title="'Create Edge stack'" breadcrumbs="[{label:'Edge Stacks', link:'edge.stacks'}, 'Create Edge stack']" reload="true"> </page-header>
|
|
||||||
|
|
||||||
<div class="row">
|
|
||||||
<div class="col-sm-12">
|
|
||||||
<rd-widget>
|
|
||||||
<rd-widget-body>
|
|
||||||
<form class="form-horizontal" name="$ctrl.form">
|
|
||||||
<!-- name-input -->
|
|
||||||
<div class="form-group">
|
|
||||||
<label for="stack_name" class="col-sm-1 control-label required text-left"> Name </label>
|
|
||||||
<div class="col-sm-11">
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
data-cy="edgeStackCreate-nameInput"
|
|
||||||
class="form-control"
|
|
||||||
ng-model="$ctrl.formValues.Name"
|
|
||||||
id="stack_name"
|
|
||||||
name="nameField"
|
|
||||||
ng-pattern="$ctrl.formValues.DeploymentType === $ctrl.EditorType.Compose ? STACK_NAME_VALIDATION_REGEX : ''"
|
|
||||||
placeholder="e.g. mystack"
|
|
||||||
auto-focus
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
<div class="help-block" ng-show="$ctrl.form.$invalid">
|
|
||||||
<div class="small text-warning">
|
|
||||||
<div ng-messages="$ctrl.form.$error">
|
|
||||||
<p ng-message="required" class="vertical-center">
|
|
||||||
<pr-icon icon="'alert-triangle'" mode="'warning'"></pr-icon>
|
|
||||||
<span>Name is required.</span>
|
|
||||||
</p>
|
|
||||||
<p ng-message="pattern" class="vertical-center">
|
|
||||||
<pr-icon icon="'alert-triangle'" mode="'warning'"></pr-icon>
|
|
||||||
<span>This field must consist of lower case alphanumeric characters, '_' or '-' (e.g. 'my-name', or 'abc-123').</span>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<!-- !name-input -->
|
|
||||||
|
|
||||||
<edge-groups-selector ng-if="$ctrl.formValues.Groups" value="$ctrl.formValues.Groups" on-change="($ctrl.onChangeGroups)"></edge-groups-selector>
|
|
||||||
|
|
||||||
<p class="col-sm-12 vertical-center help-block small text-warning" ng-if="$ctrl.formValues.DeploymentType === undefined">
|
|
||||||
<pr-icon icon="'alert-triangle'" mode="'warning'"></pr-icon> There are no available deployment types when there is more than one type of environment in your edge group
|
|
||||||
selection (e.g. Kubernetes and Docker environments). Please select edge groups that have environments of the same type.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<edge-stack-deployment-type-selector
|
|
||||||
value="$ctrl.formValues.DeploymentType"
|
|
||||||
has-docker-endpoint="$ctrl.hasType($ctrl.EnvironmentType.EdgeAgentOnDocker)"
|
|
||||||
has-kube-endpoint="$ctrl.hasType($ctrl.EnvironmentType.EdgeAgentOnKubernetes)"
|
|
||||||
on-change="($ctrl.onChangeDeploymentType)"
|
|
||||||
></edge-stack-deployment-type-selector>
|
|
||||||
|
|
||||||
<edge-stacks-docker-compose-form
|
|
||||||
ng-if="$ctrl.formValues.DeploymentType == $ctrl.EditorType.Compose"
|
|
||||||
form-values="$ctrl.formValues"
|
|
||||||
state="$ctrl.state"
|
|
||||||
template-values="$ctrl.state.templateValues"
|
|
||||||
set-template-values="$ctrl.setTemplateValues"
|
|
||||||
></edge-stacks-docker-compose-form>
|
|
||||||
|
|
||||||
<edge-stacks-kube-manifest-form
|
|
||||||
ng-if="$ctrl.formValues.DeploymentType == $ctrl.EditorType.Kubernetes"
|
|
||||||
form-values="$ctrl.formValues"
|
|
||||||
state="$ctrl.state"
|
|
||||||
></edge-stacks-kube-manifest-form>
|
|
||||||
|
|
||||||
<div ng-if="$ctrl.isBE">
|
|
||||||
<environment-variables-panel values="$ctrl.formValues.envVars" on-change="($ctrl.onEnvVarChange)"></environment-variables-panel>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- actions -->
|
|
||||||
<div class="col-sm-12 form-section-title"> Actions </div>
|
|
||||||
<div class="form-group">
|
|
||||||
<div class="col-sm-12">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="btn btn-primary btn-sm"
|
|
||||||
ng-disabled="$ctrl.state.actionInProgress || $ctrl.formIsInvalid()"
|
|
||||||
ng-click="$ctrl.createStack()"
|
|
||||||
button-spinner="$ctrl.state.actionInProgress"
|
|
||||||
analytics-on
|
|
||||||
analytics-event="edge-stack-creation"
|
|
||||||
analytics-category="edge"
|
|
||||||
analytics-properties="$ctrl.buildAnalyticsProperties()"
|
|
||||||
data-cy="edgeStackCreate-createStackButton"
|
|
||||||
>
|
|
||||||
<span ng-hide="$ctrl.state.actionInProgress">Deploy the stack</span>
|
|
||||||
<span ng-show="$ctrl.state.actionInProgress">Deployment in progress...</span>
|
|
||||||
</button>
|
|
||||||
<span class="text-danger" ng-if="$ctrl.state.formValidationError" style="margin-left: 5px"> {{ $ctrl.state.formValidationError }} </span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<!-- !actions -->
|
|
||||||
</form>
|
|
||||||
</rd-widget-body>
|
|
||||||
</rd-widget>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
|
@ -1,6 +0,0 @@
|
||||||
import controller from './create-edge-stack-view.controller';
|
|
||||||
|
|
||||||
export const createEdgeStackView = {
|
|
||||||
templateUrl: './create-edge-stack-view.html',
|
|
||||||
controller,
|
|
||||||
};
|
|
|
@ -1,51 +0,0 @@
|
||||||
import { getInitialTemplateValues } from '@/react/edge/edge-stacks/CreateView/TemplateFieldset/TemplateFieldset';
|
|
||||||
import { editor, git, edgeStackTemplate, upload } from '@@/BoxSelector/common-options/build-methods';
|
|
||||||
|
|
||||||
class DockerComposeFormController {
|
|
||||||
/* @ngInject */
|
|
||||||
constructor($async, Notifications) {
|
|
||||||
Object.assign(this, { $async, Notifications });
|
|
||||||
|
|
||||||
this.methodOptions = [editor, upload, git, edgeStackTemplate];
|
|
||||||
|
|
||||||
this.onChangeFileContent = this.onChangeFileContent.bind(this);
|
|
||||||
this.onChangeFile = this.onChangeFile.bind(this);
|
|
||||||
this.onChangeMethod = this.onChangeMethod.bind(this);
|
|
||||||
this.onChangeFormValues = this.onChangeFormValues.bind(this);
|
|
||||||
this.isGitTemplate = this.isGitTemplate.bind(this);
|
|
||||||
}
|
|
||||||
|
|
||||||
isGitTemplate() {
|
|
||||||
return this.state.Method === 'template' && !!this.templateValues.template && !!this.templateValues.template.GitConfig;
|
|
||||||
}
|
|
||||||
|
|
||||||
onChangeFormValues(newValues) {
|
|
||||||
return this.$async(async () => {
|
|
||||||
this.formValues = {
|
|
||||||
...this.formValues,
|
|
||||||
...newValues,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
onChangeMethod(method) {
|
|
||||||
this.state.Method = method;
|
|
||||||
this.formValues.StackFileContent = '';
|
|
||||||
this.setTemplateValues(getInitialTemplateValues());
|
|
||||||
}
|
|
||||||
|
|
||||||
onChangeFileContent(value) {
|
|
||||||
return this.$async(async () => {
|
|
||||||
this.formValues.StackFileContent = value;
|
|
||||||
this.state.isEditorDirty = true;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
onChangeFile(value) {
|
|
||||||
return this.$async(async () => {
|
|
||||||
this.formValues.StackFile = value;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export default DockerComposeFormController;
|
|
|
@ -1,41 +0,0 @@
|
||||||
<div class="col-sm-12 form-section-title"> Build method </div>
|
|
||||||
<box-selector slim="true" radio-name="'method'" value="$ctrl.state.Method" options="$ctrl.methodOptions" on-change="($ctrl.onChangeMethod)"></box-selector>
|
|
||||||
|
|
||||||
<!-- template -->
|
|
||||||
<div ng-if="$ctrl.state.Method === 'template'">
|
|
||||||
<edge-stack-create-template-fieldset values="$ctrl.templateValues" set-values="$ctrl.setTemplateValues"></edge-stack-create-template-fieldset>
|
|
||||||
</div>
|
|
||||||
<!-- !template -->
|
|
||||||
|
|
||||||
<web-editor-form
|
|
||||||
ng-if="$ctrl.state.Method === 'editor' || ($ctrl.state.Method === 'template' && $ctrl.templateValues.template)"
|
|
||||||
identifier="stack-creation-editor"
|
|
||||||
value="$ctrl.formValues.StackFileContent"
|
|
||||||
on-change="($ctrl.onChangeFileContent)"
|
|
||||||
ng-required="true"
|
|
||||||
yml="true"
|
|
||||||
placeholder="Define or paste the content of your docker compose file here"
|
|
||||||
versions="$ctrl.formValues.versions"
|
|
||||||
read-only="$ctrl.isGitTemplate()"
|
|
||||||
>
|
|
||||||
<editor-description>
|
|
||||||
You can get more information about Compose file format in the
|
|
||||||
<a href="https://docs.docker.com/compose/compose-file/" target="_blank"> official documentation </a>
|
|
||||||
.
|
|
||||||
</editor-description>
|
|
||||||
</web-editor-form>
|
|
||||||
|
|
||||||
<file-upload-form ng-if="$ctrl.state.Method === 'upload'" file="$ctrl.formValues.StackFile" on-change="($ctrl.onChangeFile)" ng-required="true">
|
|
||||||
<file-upload-description> You can upload a Compose file from your computer. </file-upload-description>
|
|
||||||
</file-upload-form>
|
|
||||||
|
|
||||||
<div ng-if="$ctrl.state.Method == 'repository' || $ctrl.isGitTemplate()">
|
|
||||||
<git-form
|
|
||||||
value="$ctrl.formValues"
|
|
||||||
on-change="($ctrl.onChangeFormValues)"
|
|
||||||
base-webhook-url="{{ $ctrl.state.baseWebhookUrl }}"
|
|
||||||
webhook-id="{{ $ctrl.state.webhookId }}"
|
|
||||||
created-from-custom-template-id="($ctrl.state.templateValues.type === 'custom' ? $ctrl.state.templateValues.template.Id : 0)"
|
|
||||||
docs-links
|
|
||||||
></git-form>
|
|
||||||
</div>
|
|
|
@ -1,13 +0,0 @@
|
||||||
import controller from './docker-compose-form.controller.js';
|
|
||||||
|
|
||||||
export const edgeStacksDockerComposeForm = {
|
|
||||||
templateUrl: './docker-compose-form.html',
|
|
||||||
controller,
|
|
||||||
|
|
||||||
bindings: {
|
|
||||||
formValues: '=',
|
|
||||||
state: '=',
|
|
||||||
templateValues: '<',
|
|
||||||
setTemplateValues: '<',
|
|
||||||
},
|
|
||||||
};
|
|
|
@ -1,13 +0,0 @@
|
||||||
import angular from 'angular';
|
|
||||||
|
|
||||||
import { createEdgeStackView } from './create-edge-stack-view';
|
|
||||||
import { edgeStacksDockerComposeForm } from './docker-compose-form';
|
|
||||||
import { kubeManifestForm } from './kube-manifest-form';
|
|
||||||
import { kubeDeployDescription } from './kube-deploy-description';
|
|
||||||
|
|
||||||
export default angular
|
|
||||||
.module('portainer.edge.stacks.create', [])
|
|
||||||
.component('createEdgeStackView', createEdgeStackView)
|
|
||||||
.component('edgeStacksDockerComposeForm', edgeStacksDockerComposeForm)
|
|
||||||
.component('edgeStacksKubeManifestForm', kubeManifestForm)
|
|
||||||
.component('kubeDeployDescription', kubeDeployDescription).name;
|
|
|
@ -1,3 +0,0 @@
|
||||||
export const kubeDeployDescription = {
|
|
||||||
templateUrl: './kube-deploy-description.html',
|
|
||||||
};
|
|
|
@ -1,5 +0,0 @@
|
||||||
<div>Templates allow deploying any kind of Kubernetes resource (Deployment, Secret, ConfigMap...)</div>
|
|
||||||
<div>
|
|
||||||
You can get more information about Kubernetes file format in the
|
|
||||||
<a href="https://kubernetes.io/docs/concepts/overview/working-with-objects/kubernetes-objects/" target="_blank">official documentation</a>.
|
|
||||||
</div>
|
|
|
@ -1,11 +0,0 @@
|
||||||
import controller from './kube-manifest-form.controller.js';
|
|
||||||
|
|
||||||
export const kubeManifestForm = {
|
|
||||||
templateUrl: './kube-manifest-form.html',
|
|
||||||
controller,
|
|
||||||
|
|
||||||
bindings: {
|
|
||||||
formValues: '=',
|
|
||||||
state: '=',
|
|
||||||
},
|
|
||||||
};
|
|
|
@ -1,48 +0,0 @@
|
||||||
import { editor, git, upload } from '@@/BoxSelector/common-options/build-methods';
|
|
||||||
|
|
||||||
class KubeManifestFormController {
|
|
||||||
/* @ngInject */
|
|
||||||
constructor($async) {
|
|
||||||
Object.assign(this, { $async });
|
|
||||||
|
|
||||||
this.methodOptions = [editor, upload, git];
|
|
||||||
|
|
||||||
this.onChangeFileContent = this.onChangeFileContent.bind(this);
|
|
||||||
this.onChangeFormValues = this.onChangeFormValues.bind(this);
|
|
||||||
this.onChangeFile = this.onChangeFile.bind(this);
|
|
||||||
this.onChangeMethod = this.onChangeMethod.bind(this);
|
|
||||||
this.onChangeUseManifestNamespaces = this.onChangeUseManifestNamespaces.bind(this);
|
|
||||||
}
|
|
||||||
|
|
||||||
onChangeFormValues(newValues) {
|
|
||||||
return this.$async(async () => {
|
|
||||||
this.formValues = {
|
|
||||||
...this.formValues,
|
|
||||||
...newValues,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
onChangeUseManifestNamespaces(value) {
|
|
||||||
this.onChangeFormValues({ UseManifestNamespaces: value });
|
|
||||||
}
|
|
||||||
|
|
||||||
onChangeFileContent(value) {
|
|
||||||
this.state.isEditorDirty = true;
|
|
||||||
this.formValues.StackFileContent = value;
|
|
||||||
}
|
|
||||||
|
|
||||||
onChangeFile(value) {
|
|
||||||
return this.$async(async () => {
|
|
||||||
this.formValues.StackFile = value;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
onChangeMethod(method) {
|
|
||||||
return this.$async(async () => {
|
|
||||||
this.state.Method = method;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export default KubeManifestFormController;
|
|
|
@ -1,42 +0,0 @@
|
||||||
<div class="form-group">
|
|
||||||
<div class="col-sm-12">
|
|
||||||
<por-switch-field
|
|
||||||
label="'Use namespace(s) specified from manifest'"
|
|
||||||
tooltip="'If you have defined namespaces in your deployment file turning this on will enforce the use of those only in the deployment'"
|
|
||||||
checked="$ctrl.formValues.UseManifestNamespaces"
|
|
||||||
on-change="($ctrl.onChangeUseManifestNamespaces)"
|
|
||||||
></por-switch-field>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="col-sm-12 form-section-title"> Build method </div>
|
|
||||||
<box-selector slim="true" radio-name="'method'" value="$ctrl.state.Method" options="$ctrl.methodOptions" on-change="($ctrl.onChangeMethod)"></box-selector>
|
|
||||||
|
|
||||||
<web-editor-form
|
|
||||||
ng-if="$ctrl.state.Method === 'editor'"
|
|
||||||
identifier="stack-creation-editor"
|
|
||||||
value="$ctrl.formValues.StackFileContent"
|
|
||||||
on-change="($ctrl.onChangeFileContent)"
|
|
||||||
yml="true"
|
|
||||||
placeholder="Define or paste the content of your manifest here"
|
|
||||||
ng-required="true"
|
|
||||||
>
|
|
||||||
<editor-description>
|
|
||||||
<kube-deploy-description></kube-deploy-description>
|
|
||||||
</editor-description>
|
|
||||||
</web-editor-form>
|
|
||||||
|
|
||||||
<file-upload-form ng-if="$ctrl.state.Method === 'upload'" file="$ctrl.formValues.StackFile" on-change="($ctrl.onChangeFile)" ng-required="true">
|
|
||||||
<file-upload-description>
|
|
||||||
<kube-deploy-description></kube-deploy-description>
|
|
||||||
</file-upload-description>
|
|
||||||
</file-upload-form>
|
|
||||||
|
|
||||||
<git-form
|
|
||||||
ng-if="$ctrl.state.Method === 'repository'"
|
|
||||||
deploy-method="kubernetes"
|
|
||||||
value="$ctrl.formValues"
|
|
||||||
on-change="($ctrl.onChangeFormValues)"
|
|
||||||
base-webhook-url="{{ $ctrl.state.baseWebhookUrl }}"
|
|
||||||
webhook-id="{{ $ctrl.state.webhookId }}"
|
|
||||||
></git-form>
|
|
|
@ -1,5 +1,3 @@
|
||||||
import angular from 'angular';
|
import angular from 'angular';
|
||||||
|
|
||||||
import createModule from './createEdgeStackView';
|
export default angular.module('portainer.edge.stacks', []).name;
|
||||||
|
|
||||||
export default angular.module('portainer.edge.stacks', [createModule]).name;
|
|
||||||
|
|
|
@ -1,8 +1,8 @@
|
||||||
import { SetStateAction } from 'react';
|
import { SetStateAction } from 'react';
|
||||||
|
|
||||||
export function applySetStateAction<T>(applier: SetStateAction<T>, values?: T) {
|
export function applySetStateAction<T>(applier: SetStateAction<T>, values: T) {
|
||||||
if (isFunction(applier)) {
|
if (isFunction(applier)) {
|
||||||
return values ? applier(values) : undefined;
|
return applier(values);
|
||||||
}
|
}
|
||||||
return applier;
|
return applier;
|
||||||
|
|
||||||
|
|
|
@ -6,6 +6,7 @@ test('render should include description', async () => {
|
||||||
const onClick = vi.fn();
|
const onClick = vi.fn();
|
||||||
const { findByText } = render(
|
const { findByText } = render(
|
||||||
<FileUploadForm
|
<FileUploadForm
|
||||||
|
value={undefined}
|
||||||
title="test button"
|
title="test button"
|
||||||
onChange={onClick}
|
onChange={onClick}
|
||||||
description={<span>test description</span>}
|
description={<span>test description</span>}
|
||||||
|
|
|
@ -6,8 +6,8 @@ import { FormSectionTitle } from '@@/form-components/FormSectionTitle';
|
||||||
import { FileUploadField } from '@@/form-components/FileUpload/FileUploadField';
|
import { FileUploadField } from '@@/form-components/FileUpload/FileUploadField';
|
||||||
|
|
||||||
export interface Props {
|
export interface Props {
|
||||||
onChange(value: unknown): void;
|
onChange(value?: File): void;
|
||||||
value?: File;
|
value: File | undefined;
|
||||||
title?: string;
|
title?: string;
|
||||||
required?: boolean;
|
required?: boolean;
|
||||||
description: ReactNode;
|
description: ReactNode;
|
||||||
|
|
|
@ -12,6 +12,7 @@ interface Props {
|
||||||
defaultFolded?: boolean;
|
defaultFolded?: boolean;
|
||||||
titleClassName?: string;
|
titleClassName?: string;
|
||||||
className?: string;
|
className?: string;
|
||||||
|
htmlFor?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function FormSection({
|
export function FormSection({
|
||||||
|
@ -22,13 +23,14 @@ export function FormSection({
|
||||||
defaultFolded = isFoldable,
|
defaultFolded = isFoldable,
|
||||||
titleClassName,
|
titleClassName,
|
||||||
className,
|
className,
|
||||||
|
htmlFor = '',
|
||||||
}: PropsWithChildren<Props>) {
|
}: PropsWithChildren<Props>) {
|
||||||
const [isExpanded, setIsExpanded] = useState(!defaultFolded);
|
const [isExpanded, setIsExpanded] = useState(!defaultFolded);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={className}>
|
<div className={className}>
|
||||||
<FormSectionTitle
|
<FormSectionTitle
|
||||||
htmlFor={isFoldable ? `foldingButton${title}` : ''}
|
htmlFor={isFoldable ? `foldingButton${title}` : htmlFor}
|
||||||
titleSize={titleSize}
|
titleSize={titleSize}
|
||||||
className={titleClassName}
|
className={titleClassName}
|
||||||
>
|
>
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
import clsx from 'clsx';
|
import clsx from 'clsx';
|
||||||
import uuid from 'uuid';
|
import uuid from 'uuid';
|
||||||
import { ComponentProps, PropsWithChildren, ReactNode } from 'react';
|
import { ComponentProps, PropsWithChildren, ReactNode, useState } from 'react';
|
||||||
|
|
||||||
import { FeatureId } from '@/react/portainer/feature-flags/enums';
|
import { FeatureId } from '@/react/portainer/feature-flags/enums';
|
||||||
import { AutomationTestingProps } from '@/types';
|
import { AutomationTestingProps } from '@/types';
|
||||||
|
@ -33,7 +33,7 @@ export function SwitchField({
|
||||||
checked,
|
checked,
|
||||||
label,
|
label,
|
||||||
index,
|
index,
|
||||||
name = uuid(),
|
name,
|
||||||
labelClass,
|
labelClass,
|
||||||
fieldClass,
|
fieldClass,
|
||||||
'data-cy': dataCy,
|
'data-cy': dataCy,
|
||||||
|
@ -44,13 +44,14 @@ export function SwitchField({
|
||||||
setTooltipHtmlMessage,
|
setTooltipHtmlMessage,
|
||||||
valueExplanation,
|
valueExplanation,
|
||||||
}: PropsWithChildren<Props>) {
|
}: PropsWithChildren<Props>) {
|
||||||
|
const [toggleId] = useState(() => `toggle_${uuid()}`);
|
||||||
const toggleName = name ? `toggle_${name}` : '';
|
const toggleName = name ? `toggle_${name}` : '';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={clsx(styles.root, fieldClass)}>
|
<div className={clsx(styles.root, fieldClass)}>
|
||||||
<label
|
<label
|
||||||
className={clsx('space-right control-label !p-0 text-left', labelClass)}
|
className={clsx('space-right control-label !p-0 text-left', labelClass)}
|
||||||
htmlFor={toggleName}
|
htmlFor={toggleId}
|
||||||
>
|
>
|
||||||
{label}
|
{label}
|
||||||
{tooltip && (
|
{tooltip && (
|
||||||
|
@ -60,7 +61,7 @@ export function SwitchField({
|
||||||
<Switch
|
<Switch
|
||||||
className={clsx('space-right', switchClass)}
|
className={clsx('space-right', switchClass)}
|
||||||
name={toggleName}
|
name={toggleName}
|
||||||
id={toggleName}
|
id={toggleId}
|
||||||
checked={checked}
|
checked={checked}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
onChange={onChange}
|
onChange={onChange}
|
||||||
|
|
|
@ -0,0 +1,133 @@
|
||||||
|
import { Formik } from 'formik';
|
||||||
|
import { useState } from 'react';
|
||||||
|
|
||||||
|
import { toGitFormModel } from '@/react/portainer/gitops/types';
|
||||||
|
import { getDefaultRelativePathModel } from '@/react/portainer/gitops/RelativePathFieldset/types';
|
||||||
|
import { createWebhookId } from '@/portainer/helpers/webhookHelper';
|
||||||
|
import { CustomTemplate } from '@/react/portainer/templates/custom-templates/types';
|
||||||
|
import { useCustomTemplate } from '@/react/portainer/templates/custom-templates/queries/useCustomTemplate';
|
||||||
|
import { getVariablesFieldDefaultValues } from '@/react/portainer/custom-templates/components/CustomTemplatesVariablesField';
|
||||||
|
import { useAppTemplate } from '@/react/portainer/templates/app-templates/queries/useAppTemplates';
|
||||||
|
import { TemplateViewModel } from '@/react/portainer/templates/app-templates/view-model';
|
||||||
|
import { getDefaultValues as getEnvVarDefaultValues } from '@/react/portainer/templates/app-templates/DeployFormWidget/EnvVarsFieldset';
|
||||||
|
|
||||||
|
import { Widget } from '@@/Widget';
|
||||||
|
|
||||||
|
import { DeploymentType } from '../types';
|
||||||
|
import { getDefaultStaggerConfig } from '../components/StaggerFieldset.types';
|
||||||
|
|
||||||
|
import { InnerForm } from './InnerForm';
|
||||||
|
import { FormValues } from './types';
|
||||||
|
import { useValidation } from './CreateForm.validation';
|
||||||
|
import { Values as TemplateValues } from './TemplateFieldset/types';
|
||||||
|
import { getInitialTemplateValues } from './TemplateFieldset/TemplateFieldset';
|
||||||
|
import { useTemplateParams } from './useTemplateParams';
|
||||||
|
import { useCreate } from './useCreate';
|
||||||
|
|
||||||
|
export function CreateForm() {
|
||||||
|
const [webhookId] = useState(() => createWebhookId());
|
||||||
|
|
||||||
|
const [templateParams, setTemplateParams] = useTemplateParams();
|
||||||
|
const templateQuery = useTemplate(templateParams.type, templateParams.id);
|
||||||
|
|
||||||
|
const validation = useValidation(templateQuery);
|
||||||
|
const mutation = useCreate({
|
||||||
|
webhookId,
|
||||||
|
template: templateQuery.customTemplate || templateQuery.appTemplate,
|
||||||
|
templateType: templateParams.type,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (
|
||||||
|
templateParams.id &&
|
||||||
|
!(templateQuery.customTemplate || templateQuery.appTemplate)
|
||||||
|
) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const template = templateQuery.customTemplate || templateQuery.appTemplate;
|
||||||
|
|
||||||
|
const initialValues: FormValues = {
|
||||||
|
name: '',
|
||||||
|
groupIds: [],
|
||||||
|
deploymentType: DeploymentType.Compose,
|
||||||
|
envVars: [],
|
||||||
|
privateRegistryId: 0,
|
||||||
|
prePullImage: false,
|
||||||
|
retryDeploy: false,
|
||||||
|
staggerConfig: getDefaultStaggerConfig(),
|
||||||
|
method: templateParams.id ? 'template' : 'editor',
|
||||||
|
git: toGitFormModel(),
|
||||||
|
relativePath: getDefaultRelativePathModel(),
|
||||||
|
enableWebhook: false,
|
||||||
|
fileContent: '',
|
||||||
|
templateValues: getTemplateValues(templateParams.type, template),
|
||||||
|
useManifestNamespaces: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="row">
|
||||||
|
<div className="col-sm-12">
|
||||||
|
<Widget>
|
||||||
|
<Widget.Body>
|
||||||
|
<Formik
|
||||||
|
initialValues={initialValues}
|
||||||
|
onSubmit={mutation.onSubmit}
|
||||||
|
validationSchema={validation}
|
||||||
|
>
|
||||||
|
<InnerForm
|
||||||
|
webhookId={webhookId}
|
||||||
|
isLoading={mutation.isLoading}
|
||||||
|
onChangeTemplate={setTemplateParams}
|
||||||
|
/>
|
||||||
|
</Formik>
|
||||||
|
</Widget.Body>
|
||||||
|
</Widget>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getTemplateValues(
|
||||||
|
type: 'custom' | 'app' | undefined,
|
||||||
|
template: TemplateViewModel | CustomTemplate | undefined
|
||||||
|
): TemplateValues {
|
||||||
|
if (!type) {
|
||||||
|
return getInitialTemplateValues();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (type === 'custom') {
|
||||||
|
const customTemplate = template as CustomTemplate;
|
||||||
|
return {
|
||||||
|
templateId: customTemplate.Id,
|
||||||
|
type,
|
||||||
|
variables: getVariablesFieldDefaultValues(customTemplate.Variables),
|
||||||
|
envVars: {},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const appTemplate = template as TemplateViewModel;
|
||||||
|
|
||||||
|
return {
|
||||||
|
templateId: appTemplate.Id,
|
||||||
|
type,
|
||||||
|
variables: [],
|
||||||
|
envVars: getEnvVarDefaultValues(appTemplate.Env),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function useTemplate(
|
||||||
|
type: 'app' | 'custom' | undefined,
|
||||||
|
id: number | undefined
|
||||||
|
) {
|
||||||
|
const customTemplateQuery = useCustomTemplate(id, {
|
||||||
|
enabled: !!id && type === 'custom',
|
||||||
|
});
|
||||||
|
const appTemplateQuery = useAppTemplate(id, {
|
||||||
|
enabled: !!id && type === 'app',
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
appTemplate: appTemplateQuery.data,
|
||||||
|
customTemplate: customTemplateQuery.data,
|
||||||
|
};
|
||||||
|
}
|
|
@ -0,0 +1,90 @@
|
||||||
|
import {
|
||||||
|
SchemaOf,
|
||||||
|
array,
|
||||||
|
boolean,
|
||||||
|
lazy,
|
||||||
|
mixed,
|
||||||
|
number,
|
||||||
|
object,
|
||||||
|
string,
|
||||||
|
} from 'yup';
|
||||||
|
import { useMemo } from 'react';
|
||||||
|
import Lazy from 'yup/lib/Lazy';
|
||||||
|
|
||||||
|
import { buildGitValidationSchema } from '@/react/portainer/gitops/GitForm';
|
||||||
|
import { useGitCredentials } from '@/react/portainer/account/git-credentials/git-credentials.service';
|
||||||
|
import { useCurrentUser } from '@/react/hooks/useUser';
|
||||||
|
import { relativePathValidation } from '@/react/portainer/gitops/RelativePathFieldset/validation';
|
||||||
|
import { CustomTemplate } from '@/react/portainer/templates/custom-templates/types';
|
||||||
|
import { TemplateViewModel } from '@/react/portainer/templates/app-templates/view-model';
|
||||||
|
import { GitFormModel } from '@/react/portainer/gitops/types';
|
||||||
|
|
||||||
|
import { envVarValidation } from '@@/form-components/EnvironmentVariablesFieldset';
|
||||||
|
import { file } from '@@/form-components/yup-file-validation';
|
||||||
|
|
||||||
|
import { DeploymentType } from '../types';
|
||||||
|
import { staggerConfigValidation } from '../components/StaggerFieldset';
|
||||||
|
|
||||||
|
import { FormValues, Method } from './types';
|
||||||
|
import { templateFieldsetValidation } from './TemplateFieldset/validation';
|
||||||
|
import { useNameValidation } from './NameField';
|
||||||
|
|
||||||
|
export function useValidation({
|
||||||
|
appTemplate,
|
||||||
|
customTemplate,
|
||||||
|
}: {
|
||||||
|
appTemplate: TemplateViewModel | undefined;
|
||||||
|
customTemplate: CustomTemplate | undefined;
|
||||||
|
}): Lazy<SchemaOf<FormValues>> {
|
||||||
|
const { user } = useCurrentUser();
|
||||||
|
const gitCredentialsQuery = useGitCredentials(user.Id);
|
||||||
|
const nameValidation = useNameValidation();
|
||||||
|
|
||||||
|
return useMemo(
|
||||||
|
() =>
|
||||||
|
lazy((values: FormValues) =>
|
||||||
|
object({
|
||||||
|
method: mixed<Method>()
|
||||||
|
.oneOf(['editor', 'upload', 'repository', 'template'])
|
||||||
|
.required(),
|
||||||
|
name: nameValidation(values.groupIds),
|
||||||
|
groupIds: array(number().required())
|
||||||
|
.required()
|
||||||
|
.min(1, 'At least one Edge group is required'),
|
||||||
|
deploymentType: mixed<DeploymentType>()
|
||||||
|
.oneOf([DeploymentType.Compose, DeploymentType.Kubernetes])
|
||||||
|
.required(),
|
||||||
|
envVars: envVarValidation(),
|
||||||
|
privateRegistryId: number().default(0),
|
||||||
|
prePullImage: boolean().default(false),
|
||||||
|
retryDeploy: boolean().default(false),
|
||||||
|
enableWebhook: boolean().default(false),
|
||||||
|
staggerConfig: staggerConfigValidation(),
|
||||||
|
fileContent: string()
|
||||||
|
.default('')
|
||||||
|
.when('method', {
|
||||||
|
is: 'editor',
|
||||||
|
then: (schema) => schema.required('Config file is required'),
|
||||||
|
}),
|
||||||
|
file: file().when('method', {
|
||||||
|
is: 'upload',
|
||||||
|
then: (schema) => schema.required(),
|
||||||
|
}),
|
||||||
|
templateValues: templateFieldsetValidation({
|
||||||
|
customVariablesDefinitions: customTemplate?.Variables || [],
|
||||||
|
envVarDefinitions: appTemplate?.Env || [],
|
||||||
|
}),
|
||||||
|
git: mixed().when('method', {
|
||||||
|
is: 'repository',
|
||||||
|
then: buildGitValidationSchema(
|
||||||
|
gitCredentialsQuery.data || [],
|
||||||
|
!!customTemplate
|
||||||
|
),
|
||||||
|
}) as SchemaOf<GitFormModel>,
|
||||||
|
relativePath: relativePathValidation(),
|
||||||
|
useManifestNamespaces: boolean().default(false),
|
||||||
|
})
|
||||||
|
),
|
||||||
|
[appTemplate?.Env, customTemplate, gitCredentialsQuery.data, nameValidation]
|
||||||
|
);
|
||||||
|
}
|
|
@ -0,0 +1,20 @@
|
||||||
|
import { PageHeader } from '@@/PageHeader';
|
||||||
|
|
||||||
|
import { CreateForm } from './CreateForm';
|
||||||
|
|
||||||
|
export function CreateView() {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<PageHeader
|
||||||
|
title="Create Edge stack"
|
||||||
|
breadcrumbs={[
|
||||||
|
{ label: 'Edge Stacks', link: 'edge.stacks' },
|
||||||
|
'Create Edge stack',
|
||||||
|
]}
|
||||||
|
reload
|
||||||
|
/>
|
||||||
|
|
||||||
|
<CreateForm />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
|
@ -0,0 +1,43 @@
|
||||||
|
import { SwitchField } from '@@/form-components/SwitchField';
|
||||||
|
|
||||||
|
import { FormValues } from './types';
|
||||||
|
|
||||||
|
export function DeploymentOptions({
|
||||||
|
setFieldValue,
|
||||||
|
values,
|
||||||
|
}: {
|
||||||
|
values: FormValues;
|
||||||
|
setFieldValue: <T>(field: string, value: T) => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="form-group">
|
||||||
|
<div className="col-sm-12">
|
||||||
|
<SwitchField
|
||||||
|
checked={values.prePullImage}
|
||||||
|
name="prePullImage"
|
||||||
|
label="Pre-pull images"
|
||||||
|
tooltip="When enabled, the image will be pre-pulled before deployment is started. This is useful in scenarios where the image download may be delayed or intermittent and would subsequently cause the deployment to fail"
|
||||||
|
labelClass="col-sm-3 col-lg-2"
|
||||||
|
onChange={(value) => setFieldValue('prePullImage', value)}
|
||||||
|
data-cy="pre-pull-images-switch"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="form-group">
|
||||||
|
<div className="col-sm-12">
|
||||||
|
<SwitchField
|
||||||
|
checked={values.retryDeploy}
|
||||||
|
name="retryDeploy"
|
||||||
|
label="Retry deployment"
|
||||||
|
tooltip="When enabled, this will allow the edge agent to retry deployment if failed to deploy initially"
|
||||||
|
labelClass="col-sm-3 col-lg-2"
|
||||||
|
onChange={(value) => setFieldValue('retryDeploy', value)}
|
||||||
|
data-cy="retry-deployment-switch"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
|
@ -0,0 +1,157 @@
|
||||||
|
import { useFormikContext } from 'formik';
|
||||||
|
|
||||||
|
import { GitForm } from '@/react/portainer/gitops/GitForm';
|
||||||
|
import { baseEdgeStackWebhookUrl } from '@/portainer/helpers/webhookHelper';
|
||||||
|
import { RelativePathFieldset } from '@/react/portainer/gitops/RelativePathFieldset/RelativePathFieldset';
|
||||||
|
import { applySetStateAction } from '@/react-tools/apply-set-state-action';
|
||||||
|
|
||||||
|
import { BoxSelector } from '@@/BoxSelector';
|
||||||
|
import { FormSection } from '@@/form-components/FormSection';
|
||||||
|
import {
|
||||||
|
editor,
|
||||||
|
git,
|
||||||
|
edgeStackTemplate,
|
||||||
|
upload,
|
||||||
|
} from '@@/BoxSelector/common-options/build-methods';
|
||||||
|
import { WebEditorForm } from '@@/WebEditorForm';
|
||||||
|
import { FileUploadForm } from '@@/form-components/FileUpload';
|
||||||
|
|
||||||
|
import { TemplateFieldset } from './TemplateFieldset/TemplateFieldset';
|
||||||
|
import { useRenderTemplate } from './useRenderTemplate';
|
||||||
|
import { DockerFormValues } from './types';
|
||||||
|
|
||||||
|
const buildMethods = [editor, upload, git, edgeStackTemplate] as const;
|
||||||
|
|
||||||
|
export function DockerComposeForm({
|
||||||
|
webhookId,
|
||||||
|
onChangeTemplate,
|
||||||
|
}: {
|
||||||
|
webhookId: string;
|
||||||
|
onChangeTemplate: ({
|
||||||
|
type,
|
||||||
|
id,
|
||||||
|
}: {
|
||||||
|
type: 'app' | 'custom' | undefined;
|
||||||
|
id: number | undefined;
|
||||||
|
}) => void;
|
||||||
|
}) {
|
||||||
|
const { errors, values, setValues } = useFormikContext<DockerFormValues>();
|
||||||
|
const { method } = values;
|
||||||
|
|
||||||
|
const template = useRenderTemplate(values.templateValues, setValues);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<FormSection title="Build Method">
|
||||||
|
<BoxSelector
|
||||||
|
options={buildMethods}
|
||||||
|
onChange={(value) => handleChange({ method: value })}
|
||||||
|
value={method}
|
||||||
|
radioName="method"
|
||||||
|
slim
|
||||||
|
/>
|
||||||
|
</FormSection>
|
||||||
|
|
||||||
|
{method === edgeStackTemplate.value && (
|
||||||
|
<TemplateFieldset
|
||||||
|
values={values.templateValues}
|
||||||
|
setValues={(templateAction) =>
|
||||||
|
setValues((values) => {
|
||||||
|
const templateValues = applySetStateAction(
|
||||||
|
templateAction,
|
||||||
|
values.templateValues
|
||||||
|
);
|
||||||
|
onChangeTemplate({
|
||||||
|
id: templateValues.templateId,
|
||||||
|
type: templateValues.type,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
...values,
|
||||||
|
templateValues,
|
||||||
|
};
|
||||||
|
})
|
||||||
|
}
|
||||||
|
errors={errors?.templateValues}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{(method === editor.value ||
|
||||||
|
(method === edgeStackTemplate.value && template)) && (
|
||||||
|
<WebEditorForm
|
||||||
|
id="stack-creation-editor"
|
||||||
|
value={values.fileContent}
|
||||||
|
onChange={(value) => handleChange({ fileContent: value })}
|
||||||
|
yaml
|
||||||
|
placeholder="Define or paste the content of your docker compose file here"
|
||||||
|
error={errors?.fileContent}
|
||||||
|
readonly={method === edgeStackTemplate.value && !!template?.GitConfig}
|
||||||
|
data-cy="stack-creation-editor"
|
||||||
|
>
|
||||||
|
You can get more information about Compose file format in the{' '}
|
||||||
|
<a
|
||||||
|
href="https://docs.docker.com/compose/compose-file/"
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
>
|
||||||
|
official documentation
|
||||||
|
</a>
|
||||||
|
.
|
||||||
|
</WebEditorForm>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{method === upload.value && (
|
||||||
|
<FileUploadForm
|
||||||
|
value={values.file}
|
||||||
|
onChange={(File) => handleChange({ file: File })}
|
||||||
|
required
|
||||||
|
description="You can upload a Compose file from your computer."
|
||||||
|
data-cy="stack-creation-file-upload"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{method === git.value && (
|
||||||
|
<>
|
||||||
|
<GitForm
|
||||||
|
errors={errors?.git}
|
||||||
|
value={values.git}
|
||||||
|
onChange={(gitValues) =>
|
||||||
|
setValues((values) => ({
|
||||||
|
...values,
|
||||||
|
git: {
|
||||||
|
...values.git,
|
||||||
|
...gitValues,
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
baseWebhookUrl={baseEdgeStackWebhookUrl()}
|
||||||
|
webhookId={webhookId}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormSection title="Advanced configurations">
|
||||||
|
<RelativePathFieldset
|
||||||
|
values={values.relativePath}
|
||||||
|
gitModel={values.git}
|
||||||
|
onChange={(relativePath) =>
|
||||||
|
setValues((values) => ({
|
||||||
|
...values,
|
||||||
|
relativePath: {
|
||||||
|
...values.relativePath,
|
||||||
|
...relativePath,
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</FormSection>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
function handleChange(newValues: Partial<DockerFormValues>) {
|
||||||
|
setValues((values) => ({
|
||||||
|
...values,
|
||||||
|
...newValues,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,139 @@
|
||||||
|
import { Form, useFormikContext } from 'formik';
|
||||||
|
|
||||||
|
import { applySetStateAction } from '@/react-tools/apply-set-state-action';
|
||||||
|
import { EnvironmentType } from '@/react/portainer/environments/types';
|
||||||
|
|
||||||
|
import { TextTip } from '@@/Tip/TextTip';
|
||||||
|
import { EnvironmentVariablesPanel } from '@@/form-components/EnvironmentVariablesFieldset';
|
||||||
|
import { FormActions } from '@@/form-components/FormActions';
|
||||||
|
|
||||||
|
import { EdgeGroupsSelector } from '../components/EdgeGroupsSelector';
|
||||||
|
import { EdgeStackDeploymentTypeSelector } from '../components/EdgeStackDeploymentTypeSelector';
|
||||||
|
import { StaggerFieldset } from '../components/StaggerFieldset';
|
||||||
|
import { PrivateRegistryFieldsetWrapper } from '../ItemView/EditEdgeStackForm/PrivateRegistryFieldsetWrapper';
|
||||||
|
import { useValidateEnvironmentTypes } from '../ItemView/EditEdgeStackForm/useEdgeGroupHasType';
|
||||||
|
import { DeploymentType } from '../types';
|
||||||
|
|
||||||
|
import { DockerComposeForm } from './DockerComposeForm';
|
||||||
|
import { KubeFormValues, KubeManifestForm } from './KubeManifestForm';
|
||||||
|
import { NameField } from './NameField';
|
||||||
|
import { WebhookSwitch } from './WebhookSwitch';
|
||||||
|
import { FormValues } from './types';
|
||||||
|
import { DeploymentOptions } from './DeploymentOptions';
|
||||||
|
|
||||||
|
export function InnerForm({
|
||||||
|
webhookId,
|
||||||
|
isLoading,
|
||||||
|
onChangeTemplate,
|
||||||
|
}: {
|
||||||
|
webhookId: string;
|
||||||
|
isLoading: boolean;
|
||||||
|
onChangeTemplate: ({
|
||||||
|
type,
|
||||||
|
id,
|
||||||
|
}: {
|
||||||
|
type: 'app' | 'custom' | undefined;
|
||||||
|
id: number | undefined;
|
||||||
|
}) => void;
|
||||||
|
}) {
|
||||||
|
const { values, setFieldValue, errors, setValues, setFieldError, isValid } =
|
||||||
|
useFormikContext<FormValues>();
|
||||||
|
const { hasType } = useValidateEnvironmentTypes(values.groupIds);
|
||||||
|
|
||||||
|
const hasKubeEndpoint = hasType(EnvironmentType.EdgeAgentOnKubernetes);
|
||||||
|
const hasDockerEndpoint = hasType(EnvironmentType.EdgeAgentOnDocker);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Form className="form-horizontal">
|
||||||
|
<NameField
|
||||||
|
onChange={(value) => setFieldValue('name', value)}
|
||||||
|
value={values.name}
|
||||||
|
errors={errors.name}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<EdgeGroupsSelector
|
||||||
|
value={values.groupIds}
|
||||||
|
onChange={(value) => setFieldValue('groupIds', value)}
|
||||||
|
error={errors.groupIds}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{hasKubeEndpoint && hasDockerEndpoint && (
|
||||||
|
<TextTip>
|
||||||
|
There are no available deployment types when there is more than one
|
||||||
|
type of environment in your edge group selection (e.g. Kubernetes and
|
||||||
|
Docker environments). Please select edge groups that have environments
|
||||||
|
of the same type.
|
||||||
|
</TextTip>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<EdgeStackDeploymentTypeSelector
|
||||||
|
value={values.deploymentType}
|
||||||
|
hasDockerEndpoint={hasDockerEndpoint}
|
||||||
|
hasKubeEndpoint={hasKubeEndpoint}
|
||||||
|
onChange={(value) => setFieldValue('deploymentType', value)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{values.deploymentType === DeploymentType.Compose && (
|
||||||
|
<DockerComposeForm
|
||||||
|
webhookId={webhookId}
|
||||||
|
onChangeTemplate={onChangeTemplate}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{values.deploymentType === DeploymentType.Kubernetes && (
|
||||||
|
<KubeManifestForm
|
||||||
|
values={values as KubeFormValues}
|
||||||
|
webhookId={webhookId}
|
||||||
|
errors={errors}
|
||||||
|
setValues={(kubeValues) =>
|
||||||
|
setValues((values) => ({
|
||||||
|
...values,
|
||||||
|
...applySetStateAction(kubeValues, values as KubeFormValues),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{values.method !== 'repository' && (
|
||||||
|
<WebhookSwitch
|
||||||
|
onChange={(value) => setFieldValue('enableWebhook', value)}
|
||||||
|
value={values.enableWebhook}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{values.deploymentType === DeploymentType.Compose && (
|
||||||
|
<EnvironmentVariablesPanel
|
||||||
|
values={values.envVars}
|
||||||
|
onChange={(value) => setFieldValue('envVars', value)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<PrivateRegistryFieldsetWrapper
|
||||||
|
onChange={(value) => setFieldValue('privateRegistryId', value)}
|
||||||
|
value={values.privateRegistryId}
|
||||||
|
values={{ fileContent: values.fileContent, file: values.file }}
|
||||||
|
error={errors.privateRegistryId}
|
||||||
|
onFieldError={(message) => setFieldError('privateRegistryId', message)}
|
||||||
|
isGit={values.method === 'repository'}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{values.deploymentType === DeploymentType.Compose && (
|
||||||
|
<DeploymentOptions values={values} setFieldValue={setFieldValue} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
<StaggerFieldset
|
||||||
|
isEdit={false}
|
||||||
|
values={values.staggerConfig}
|
||||||
|
onChange={(value) => setFieldValue('staggerConfig', value)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormActions
|
||||||
|
data-cy="edgeStackCreate-createStackButton"
|
||||||
|
submitLabel="Deploy the stack"
|
||||||
|
loadingText="Deployment in progress..."
|
||||||
|
isValid={isValid}
|
||||||
|
isLoading={isLoading}
|
||||||
|
/>
|
||||||
|
</Form>
|
||||||
|
);
|
||||||
|
}
|
|
@ -0,0 +1,144 @@
|
||||||
|
import { SetStateAction } from 'react';
|
||||||
|
import { FormikErrors } from 'formik';
|
||||||
|
|
||||||
|
import { GitForm } from '@/react/portainer/gitops/GitForm';
|
||||||
|
import { GitFormModel } from '@/react/portainer/gitops/types';
|
||||||
|
import { baseEdgeStackWebhookUrl } from '@/portainer/helpers/webhookHelper';
|
||||||
|
|
||||||
|
import { BoxSelector } from '@@/BoxSelector';
|
||||||
|
import { WebEditorForm } from '@@/WebEditorForm';
|
||||||
|
import { FileUploadForm } from '@@/form-components/FileUpload';
|
||||||
|
import { SwitchField } from '@@/form-components/SwitchField';
|
||||||
|
import { FormSection } from '@@/form-components/FormSection';
|
||||||
|
import {
|
||||||
|
editor,
|
||||||
|
git,
|
||||||
|
upload,
|
||||||
|
} from '@@/BoxSelector/common-options/build-methods';
|
||||||
|
|
||||||
|
const buildMethods = [editor, upload, git] as const;
|
||||||
|
|
||||||
|
export interface KubeFormValues {
|
||||||
|
method: 'editor' | 'upload' | 'repository' | 'template';
|
||||||
|
useManifestNamespaces: boolean;
|
||||||
|
fileContent: string;
|
||||||
|
file?: File;
|
||||||
|
git: GitFormModel;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function KubeManifestForm({
|
||||||
|
errors,
|
||||||
|
values,
|
||||||
|
setValues,
|
||||||
|
webhookId,
|
||||||
|
}: {
|
||||||
|
errors?: FormikErrors<KubeFormValues>;
|
||||||
|
values: KubeFormValues;
|
||||||
|
setValues: (values: SetStateAction<KubeFormValues>) => void;
|
||||||
|
webhookId: string;
|
||||||
|
}) {
|
||||||
|
const { method } = values;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="form-group">
|
||||||
|
<div className="col-sm-12">
|
||||||
|
<SwitchField
|
||||||
|
label="Use namespace(s) specified from manifest"
|
||||||
|
tooltip="If you have defined namespaces in your deployment file turning this on will enforce the use of those only in the deployment"
|
||||||
|
checked={values.useManifestNamespaces}
|
||||||
|
onChange={(value) =>
|
||||||
|
handleChange({
|
||||||
|
useManifestNamespaces: value,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
data-cy="use-manifest-namespaces-switch"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<FormSection title="Build Method">
|
||||||
|
<BoxSelector
|
||||||
|
options={buildMethods}
|
||||||
|
onChange={(value) => handleChange({ method: value })}
|
||||||
|
value={method}
|
||||||
|
radioName="method"
|
||||||
|
slim
|
||||||
|
/>
|
||||||
|
</FormSection>
|
||||||
|
|
||||||
|
{method === editor.value && (
|
||||||
|
<WebEditorForm
|
||||||
|
id="stack-creation-editor"
|
||||||
|
value={values.fileContent}
|
||||||
|
onChange={(value) => handleChange({ fileContent: value })}
|
||||||
|
yaml
|
||||||
|
placeholder="Define or paste the content of your manifest file here"
|
||||||
|
error={errors?.fileContent}
|
||||||
|
data-cy="stack-creation-editor"
|
||||||
|
>
|
||||||
|
<KubeDeployDescription />
|
||||||
|
</WebEditorForm>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{method === upload.value && (
|
||||||
|
<FileUploadForm
|
||||||
|
value={values.file}
|
||||||
|
onChange={(file) => handleChange({ file })}
|
||||||
|
required
|
||||||
|
description="You can upload a Manifest file from your computer."
|
||||||
|
data-cy="stack-creation-file-upload"
|
||||||
|
>
|
||||||
|
<KubeDeployDescription />
|
||||||
|
</FileUploadForm>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{method === git.value && (
|
||||||
|
<GitForm
|
||||||
|
errors={errors?.git}
|
||||||
|
value={values.git}
|
||||||
|
onChange={(gitValues) =>
|
||||||
|
setValues((values) => ({
|
||||||
|
...values,
|
||||||
|
git: {
|
||||||
|
...values.git,
|
||||||
|
...gitValues,
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
baseWebhookUrl={baseEdgeStackWebhookUrl()}
|
||||||
|
webhookId={webhookId}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
function handleChange(newValues: Partial<KubeFormValues>) {
|
||||||
|
setValues((values) => ({
|
||||||
|
...values,
|
||||||
|
...newValues,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function KubeDeployDescription() {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div>
|
||||||
|
Templates allow deploying any kind of Kubernetes resource (Deployment,
|
||||||
|
Secret, ConfigMap...)
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
You can get more information about Kubernetes file format in the
|
||||||
|
<a
|
||||||
|
href="https://kubernetes.io/docs/concepts/overview/working-with-objects/kubernetes-objects/"
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
>
|
||||||
|
official documentation
|
||||||
|
</a>
|
||||||
|
.
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
|
@ -1,12 +1,17 @@
|
||||||
import { FormikErrors } from 'formik';
|
import { FormikErrors } from 'formik';
|
||||||
import { SchemaOf, string } from 'yup';
|
import { SchemaOf, string } from 'yup';
|
||||||
|
import { useMemo } from 'react';
|
||||||
|
|
||||||
import { STACK_NAME_VALIDATION_REGEX } from '@/react/constants';
|
import { STACK_NAME_VALIDATION_REGEX } from '@/react/constants';
|
||||||
|
import { EnvironmentType } from '@/react/portainer/environments/types';
|
||||||
|
|
||||||
import { FormControl } from '@@/form-components/FormControl';
|
import { FormControl } from '@@/form-components/FormControl';
|
||||||
import { Input } from '@@/form-components/Input';
|
import { Input } from '@@/form-components/Input';
|
||||||
|
|
||||||
import { EdgeStack } from '../types';
|
import { EdgeStack } from '../types';
|
||||||
|
import { useEdgeStacks } from '../queries/useEdgeStacks';
|
||||||
|
import { useEdgeGroups } from '../../edge-groups/queries/useEdgeGroups';
|
||||||
|
import { EdgeGroup } from '../../edge-groups/types';
|
||||||
|
|
||||||
export function NameField({
|
export function NameField({
|
||||||
onChange,
|
onChange,
|
||||||
|
@ -24,7 +29,7 @@ export function NameField({
|
||||||
onChange={(e) => onChange(e.target.value)}
|
onChange={(e) => onChange(e.target.value)}
|
||||||
value={value}
|
value={value}
|
||||||
required
|
required
|
||||||
data-cy="edge-stack-create-name-input"
|
data-cy="edgeStackCreate-nameInput"
|
||||||
/>
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
);
|
);
|
||||||
|
@ -49,3 +54,23 @@ export function nameValidation(
|
||||||
|
|
||||||
return schema;
|
return schema;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function useNameValidation() {
|
||||||
|
const edgeStacksQuery = useEdgeStacks();
|
||||||
|
const edgeGroupsQuery = useEdgeGroups({
|
||||||
|
select: (groups) =>
|
||||||
|
Object.fromEntries(groups.map((g) => [g.Id, g.EndpointTypes])),
|
||||||
|
});
|
||||||
|
const edgeGroupsType = edgeGroupsQuery.data;
|
||||||
|
|
||||||
|
return useMemo(
|
||||||
|
() => (groupIds: Array<EdgeGroup['Id']>) =>
|
||||||
|
nameValidation(
|
||||||
|
edgeStacksQuery.data || [],
|
||||||
|
groupIds
|
||||||
|
.flatMap((g) => edgeGroupsType?.[g])
|
||||||
|
?.includes(EnvironmentType.EdgeAgentOnDocker)
|
||||||
|
),
|
||||||
|
[edgeGroupsType, edgeStacksQuery.data]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
|
@ -1,13 +1,17 @@
|
||||||
import { render, screen } from '@testing-library/react';
|
import { render, screen } from '@testing-library/react';
|
||||||
|
import { HttpResponse, http } from 'msw';
|
||||||
|
|
||||||
|
import { EnvVarType } from '@/react/portainer/templates/app-templates/view-model';
|
||||||
import {
|
import {
|
||||||
EnvVarType,
|
AppTemplate,
|
||||||
TemplateViewModel,
|
TemplateType,
|
||||||
} from '@/react/portainer/templates/app-templates/view-model';
|
} from '@/react/portainer/templates/app-templates/types';
|
||||||
|
import { server } from '@/setup-tests/server';
|
||||||
|
import { withTestQueryProvider } from '@/react/test-utils/withTestQuery';
|
||||||
|
|
||||||
import { AppTemplateFieldset } from './AppTemplateFieldset';
|
import { AppTemplateFieldset } from './AppTemplateFieldset';
|
||||||
|
|
||||||
test('renders AppTemplateFieldset component', () => {
|
test('renders AppTemplateFieldset component', async () => {
|
||||||
const testedEnv = {
|
const testedEnv = {
|
||||||
name: 'VAR2',
|
name: 'VAR2',
|
||||||
label: 'Variable 2',
|
label: 'Variable 2',
|
||||||
|
@ -27,27 +31,44 @@ test('renders AppTemplateFieldset component', () => {
|
||||||
testedEnv,
|
testedEnv,
|
||||||
];
|
];
|
||||||
const template = {
|
const template = {
|
||||||
Note: 'This is a template note',
|
id: 1,
|
||||||
Env: env,
|
note: 'This is a template note',
|
||||||
} as TemplateViewModel;
|
env,
|
||||||
|
type: TemplateType.ComposeStack,
|
||||||
|
categories: ['edge'],
|
||||||
|
title: 'Template title',
|
||||||
|
description: 'Template description',
|
||||||
|
administrator_only: false,
|
||||||
|
image: 'template-image',
|
||||||
|
repository: {
|
||||||
|
url: '',
|
||||||
|
stackfile: '',
|
||||||
|
},
|
||||||
|
} satisfies AppTemplate;
|
||||||
|
|
||||||
const values: Record<string, string> = {
|
const values: Record<string, string> = {
|
||||||
VAR1: 'value1',
|
VAR1: 'value1',
|
||||||
VAR2: 'value2',
|
VAR2: 'value2',
|
||||||
};
|
};
|
||||||
|
|
||||||
const onChange = vi.fn();
|
server.use(
|
||||||
|
http.get('/api/templates', () =>
|
||||||
render(
|
HttpResponse.json({ version: '3', templates: [template] })
|
||||||
<AppTemplateFieldset
|
),
|
||||||
template={template}
|
http.get('/api/registries', () => HttpResponse.json([]))
|
||||||
values={values}
|
|
||||||
onChange={onChange}
|
|
||||||
/>
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const templateNoteElement = screen.getByText('This is a template note');
|
const onChange = vi.fn();
|
||||||
expect(templateNoteElement).toBeInTheDocument();
|
const Wrapped = withTestQueryProvider(AppTemplateFieldset);
|
||||||
|
render(
|
||||||
|
<Wrapped templateId={template.id} values={values} onChange={onChange} />
|
||||||
|
);
|
||||||
|
|
||||||
|
screen.debug();
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
screen.findByText('This is a template note')
|
||||||
|
).resolves.toBeInTheDocument();
|
||||||
|
|
||||||
const envVarsFieldsetElement = screen.getByLabelText(testedEnv.label, {
|
const envVarsFieldsetElement = screen.getByLabelText(testedEnv.label, {
|
||||||
exact: false,
|
exact: false,
|
||||||
|
|
|
@ -1,23 +1,31 @@
|
||||||
import { FormikErrors } from 'formik';
|
import { FormikErrors } from 'formik';
|
||||||
|
|
||||||
import { TemplateViewModel } from '@/react/portainer/templates/app-templates/view-model';
|
import { TemplateViewModel } from '@/react/portainer/templates/app-templates/view-model';
|
||||||
|
import { useAppTemplate } from '@/react/portainer/templates/app-templates/queries/useAppTemplates';
|
||||||
|
import { TemplateNote } from '@/react/portainer/templates/components/TemplateNote';
|
||||||
import {
|
import {
|
||||||
EnvVarsFieldset,
|
EnvVarsFieldset,
|
||||||
EnvVarsValue,
|
EnvVarsValue,
|
||||||
} from '@/react/portainer/templates/app-templates/DeployFormWidget/EnvVarsFieldset';
|
} from '@/react/portainer/templates/app-templates/DeployFormWidget/EnvVarsFieldset';
|
||||||
import { TemplateNote } from '@/react/portainer/templates/components/TemplateNote';
|
|
||||||
|
|
||||||
export function AppTemplateFieldset({
|
export function AppTemplateFieldset({
|
||||||
template,
|
templateId,
|
||||||
values,
|
values,
|
||||||
onChange,
|
onChange,
|
||||||
errors,
|
errors,
|
||||||
}: {
|
}: {
|
||||||
template: TemplateViewModel;
|
templateId: TemplateViewModel['Id'];
|
||||||
values: EnvVarsValue;
|
values: EnvVarsValue;
|
||||||
onChange: (value: EnvVarsValue) => void;
|
onChange: (value: EnvVarsValue) => void;
|
||||||
errors?: FormikErrors<EnvVarsValue>;
|
errors?: FormikErrors<EnvVarsValue>;
|
||||||
}) {
|
}) {
|
||||||
|
const templateQuery = useAppTemplate(templateId);
|
||||||
|
if (!templateQuery.data) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const template = templateQuery.data;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<TemplateNote note={template.Note} />
|
<TemplateNote note={template.Note} />
|
||||||
|
|
|
@ -1,6 +1,7 @@
|
||||||
import { CustomTemplatesVariablesField } from '@/react/portainer/custom-templates/components/CustomTemplatesVariablesField';
|
import { CustomTemplatesVariablesField } from '@/react/portainer/custom-templates/components/CustomTemplatesVariablesField';
|
||||||
import { CustomTemplate } from '@/react/portainer/templates/custom-templates/types';
|
import { CustomTemplate } from '@/react/portainer/templates/custom-templates/types';
|
||||||
import { TemplateNote } from '@/react/portainer/templates/components/TemplateNote';
|
import { TemplateNote } from '@/react/portainer/templates/components/TemplateNote';
|
||||||
|
import { useCustomTemplate } from '@/react/portainer/templates/custom-templates/queries/useCustomTemplate';
|
||||||
|
|
||||||
import { ArrayError } from '@@/form-components/InputList/InputList';
|
import { ArrayError } from '@@/form-components/InputList/InputList';
|
||||||
|
|
||||||
|
@ -10,13 +11,21 @@ export function CustomTemplateFieldset({
|
||||||
errors,
|
errors,
|
||||||
onChange,
|
onChange,
|
||||||
values,
|
values,
|
||||||
template,
|
templateId,
|
||||||
}: {
|
}: {
|
||||||
values: Values['variables'];
|
values: Values['variables'];
|
||||||
onChange: (values: Values['variables']) => void;
|
onChange: (values: Values['variables']) => void;
|
||||||
errors: ArrayError<Values['variables']> | undefined;
|
errors: ArrayError<Values['variables']> | undefined;
|
||||||
template: CustomTemplate;
|
templateId: CustomTemplate['Id'];
|
||||||
}) {
|
}) {
|
||||||
|
const templateQuery = useCustomTemplate(templateId);
|
||||||
|
|
||||||
|
if (!templateQuery.data) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const template = templateQuery.data;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<TemplateNote note={template.Note} />
|
<TemplateNote note={template.Note} />
|
||||||
|
|
|
@ -1,49 +1,38 @@
|
||||||
import { SetStateAction, useEffect, useState } from 'react';
|
import { SetStateAction } from 'react';
|
||||||
import { FormikErrors } from 'formik';
|
import { FormikErrors } from 'formik';
|
||||||
|
|
||||||
import { getVariablesFieldDefaultValues } from '@/react/portainer/custom-templates/components/CustomTemplatesVariablesField';
|
import { getVariablesFieldDefaultValues } from '@/react/portainer/custom-templates/components/CustomTemplatesVariablesField';
|
||||||
|
import { TemplateViewModel } from '@/react/portainer/templates/app-templates/view-model';
|
||||||
|
import { CustomTemplate } from '@/react/portainer/templates/custom-templates/types';
|
||||||
|
|
||||||
import { getDefaultValues as getAppVariablesDefaultValues } from '../../../../portainer/templates/app-templates/DeployFormWidget/EnvVarsFieldset';
|
import { getDefaultValues as getAppVariablesDefaultValues } from '../../../../portainer/templates/app-templates/DeployFormWidget/EnvVarsFieldset';
|
||||||
|
|
||||||
import { TemplateSelector } from './TemplateSelector';
|
import { TemplateSelector } from './TemplateSelector';
|
||||||
import { SelectedTemplateValue, Values } from './types';
|
import { Values } from './types';
|
||||||
import { CustomTemplateFieldset } from './CustomTemplateFieldset';
|
import { CustomTemplateFieldset } from './CustomTemplateFieldset';
|
||||||
import { AppTemplateFieldset } from './AppTemplateFieldset';
|
import { AppTemplateFieldset } from './AppTemplateFieldset';
|
||||||
|
|
||||||
export function TemplateFieldset({
|
export function TemplateFieldset({
|
||||||
values: initialValues,
|
values,
|
||||||
setValues: setInitialValues,
|
setValues,
|
||||||
errors,
|
errors,
|
||||||
}: {
|
}: {
|
||||||
errors?: FormikErrors<Values>;
|
errors?: FormikErrors<Values>;
|
||||||
values: Values;
|
values: Values;
|
||||||
setValues: (values: SetStateAction<Values>) => void;
|
setValues: (values: SetStateAction<Values>) => void;
|
||||||
}) {
|
}) {
|
||||||
const [values, setControlledValues] = useState(initialValues); // todo remove when all view is in react
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (
|
|
||||||
initialValues.type !== values.type ||
|
|
||||||
initialValues.template?.Id !== values.template?.Id
|
|
||||||
) {
|
|
||||||
setControlledValues(initialValues);
|
|
||||||
}
|
|
||||||
}, [initialValues, values]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<TemplateSelector
|
<TemplateSelector
|
||||||
error={
|
error={errors?.templateId}
|
||||||
typeof errors?.template === 'string' ? errors?.template : undefined
|
|
||||||
}
|
|
||||||
value={values}
|
value={values}
|
||||||
onChange={handleChangeTemplate}
|
onChange={handleChangeTemplate}
|
||||||
/>
|
/>
|
||||||
{values.template && (
|
{values.templateId && (
|
||||||
<>
|
<>
|
||||||
{values.type === 'custom' && (
|
{values.type === 'custom' && (
|
||||||
<CustomTemplateFieldset
|
<CustomTemplateFieldset
|
||||||
template={values.template}
|
templateId={values.templateId}
|
||||||
values={values.variables}
|
values={values.variables}
|
||||||
onChange={(variables) =>
|
onChange={(variables) =>
|
||||||
setValues((values) => ({ ...values, variables }))
|
setValues((values) => ({ ...values, variables }))
|
||||||
|
@ -54,7 +43,7 @@ export function TemplateFieldset({
|
||||||
|
|
||||||
{values.type === 'app' && (
|
{values.type === 'app' && (
|
||||||
<AppTemplateFieldset
|
<AppTemplateFieldset
|
||||||
template={values.template}
|
templateId={values.templateId}
|
||||||
values={values.envVars}
|
values={values.envVars}
|
||||||
onChange={(envVars) =>
|
onChange={(envVars) =>
|
||||||
setValues((values) => ({ ...values, envVars }))
|
setValues((values) => ({ ...values, envVars }))
|
||||||
|
@ -67,36 +56,36 @@ export function TemplateFieldset({
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|
||||||
function setValues(values: SetStateAction<Values>) {
|
function handleChangeTemplate(
|
||||||
setControlledValues(values);
|
template: TemplateViewModel | CustomTemplate | undefined,
|
||||||
setInitialValues(values);
|
type: 'app' | 'custom' | undefined
|
||||||
}
|
): void {
|
||||||
|
|
||||||
function handleChangeTemplate(value?: SelectedTemplateValue) {
|
|
||||||
setValues(() => {
|
setValues(() => {
|
||||||
if (!value || !value.type || !value.template) {
|
if (!template || !type) {
|
||||||
return {
|
return {
|
||||||
type: undefined,
|
type: undefined,
|
||||||
template: undefined,
|
templateId: undefined,
|
||||||
variables: [],
|
variables: [],
|
||||||
envVars: {},
|
envVars: {},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
if (value.type === 'app') {
|
if (type === 'app') {
|
||||||
return {
|
return {
|
||||||
template: value.template,
|
templateId: template.Id,
|
||||||
type: value.type,
|
type,
|
||||||
variables: [],
|
variables: [],
|
||||||
envVars: getAppVariablesDefaultValues(value.template.Env || []),
|
envVars: getAppVariablesDefaultValues(
|
||||||
|
(template as TemplateViewModel).Env || []
|
||||||
|
),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
template: value.template,
|
templateId: template.Id,
|
||||||
type: value.type,
|
type,
|
||||||
variables: getVariablesFieldDefaultValues(
|
variables: getVariablesFieldDefaultValues(
|
||||||
value.template.Variables || []
|
(template as CustomTemplate).Variables || []
|
||||||
),
|
),
|
||||||
envVars: {},
|
envVars: {},
|
||||||
};
|
};
|
||||||
|
@ -106,10 +95,9 @@ export function TemplateFieldset({
|
||||||
|
|
||||||
export function getInitialTemplateValues(): Values {
|
export function getInitialTemplateValues(): Values {
|
||||||
return {
|
return {
|
||||||
template: undefined,
|
templateId: undefined,
|
||||||
type: undefined,
|
type: undefined,
|
||||||
variables: [],
|
variables: [],
|
||||||
file: '',
|
|
||||||
envVars: {},
|
envVars: {},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
@ -7,8 +7,8 @@ import { CustomTemplate } from '@/react/portainer/templates/custom-templates/typ
|
||||||
import { server } from '@/setup-tests/server';
|
import { server } from '@/setup-tests/server';
|
||||||
import selectEvent from '@/react/test-utils/react-select';
|
import selectEvent from '@/react/test-utils/react-select';
|
||||||
import { withTestQueryProvider } from '@/react/test-utils/withTestQuery';
|
import { withTestQueryProvider } from '@/react/test-utils/withTestQuery';
|
||||||
|
import { TemplateViewModel } from '@/react/portainer/templates/app-templates/view-model';
|
||||||
|
|
||||||
import { SelectedTemplateValue } from './types';
|
|
||||||
import { TemplateSelector } from './TemplateSelector';
|
import { TemplateSelector } from './TemplateSelector';
|
||||||
|
|
||||||
test('renders TemplateSelector component', async () => {
|
test('renders TemplateSelector component', async () => {
|
||||||
|
@ -109,7 +109,10 @@ function renderComponent({
|
||||||
customTemplates = [],
|
customTemplates = [],
|
||||||
error,
|
error,
|
||||||
}: {
|
}: {
|
||||||
onChange?: (value: SelectedTemplateValue) => void;
|
onChange?: (
|
||||||
|
template: TemplateViewModel | CustomTemplate | undefined,
|
||||||
|
type: 'app' | 'custom' | undefined
|
||||||
|
) => void;
|
||||||
appTemplates?: Array<Partial<AppTemplate>>;
|
appTemplates?: Array<Partial<AppTemplate>>;
|
||||||
customTemplates?: Array<Partial<CustomTemplate>>;
|
customTemplates?: Array<Partial<CustomTemplate>>;
|
||||||
error?: string;
|
error?: string;
|
||||||
|
@ -128,7 +131,7 @@ function renderComponent({
|
||||||
|
|
||||||
render(
|
render(
|
||||||
<Wrapped
|
<Wrapped
|
||||||
value={{ template: undefined, type: undefined }}
|
value={{ templateId: undefined, type: undefined }}
|
||||||
onChange={onChange}
|
onChange={onChange}
|
||||||
error={error}
|
error={error}
|
||||||
/>
|
/>
|
||||||
|
|
|
@ -4,6 +4,8 @@ import { GroupBase } from 'react-select';
|
||||||
import { useCustomTemplates } from '@/react/portainer/templates/custom-templates/queries/useCustomTemplates';
|
import { useCustomTemplates } from '@/react/portainer/templates/custom-templates/queries/useCustomTemplates';
|
||||||
import { useAppTemplates } from '@/react/portainer/templates/app-templates/queries/useAppTemplates';
|
import { useAppTemplates } from '@/react/portainer/templates/app-templates/queries/useAppTemplates';
|
||||||
import { TemplateType } from '@/react/portainer/templates/app-templates/types';
|
import { TemplateType } from '@/react/portainer/templates/app-templates/types';
|
||||||
|
import { TemplateViewModel } from '@/react/portainer/templates/app-templates/view-model';
|
||||||
|
import { CustomTemplate } from '@/react/portainer/templates/custom-templates/types';
|
||||||
|
|
||||||
import { FormControl } from '@@/form-components/FormControl';
|
import { FormControl } from '@@/form-components/FormControl';
|
||||||
import { Select as ReactSelect } from '@@/form-components/ReactSelect';
|
import { Select as ReactSelect } from '@@/form-components/ReactSelect';
|
||||||
|
@ -16,10 +18,13 @@ export function TemplateSelector({
|
||||||
error,
|
error,
|
||||||
}: {
|
}: {
|
||||||
value: SelectedTemplateValue;
|
value: SelectedTemplateValue;
|
||||||
onChange: (value: SelectedTemplateValue) => void;
|
onChange: (
|
||||||
|
template: TemplateViewModel | CustomTemplate | undefined,
|
||||||
|
type: 'app' | 'custom' | undefined
|
||||||
|
) => void;
|
||||||
error?: string;
|
error?: string;
|
||||||
}) {
|
}) {
|
||||||
const { getTemplate, options } = useOptions();
|
const { options, getTemplate } = useOptions();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<FormControl label="Template" inputId="template_selector" errors={error}>
|
<FormControl label="Template" inputId="template_selector" errors={error}>
|
||||||
|
@ -28,26 +33,20 @@ export function TemplateSelector({
|
||||||
formatGroupLabel={GroupLabel}
|
formatGroupLabel={GroupLabel}
|
||||||
placeholder="Select an Edge stack template"
|
placeholder="Select an Edge stack template"
|
||||||
value={{
|
value={{
|
||||||
label: value.template?.Title,
|
templateId: value.templateId,
|
||||||
id: value.template?.Id,
|
|
||||||
type: value.type,
|
type: value.type,
|
||||||
}}
|
}}
|
||||||
onChange={(value) => {
|
onChange={(value) => {
|
||||||
if (!value) {
|
if (!value) {
|
||||||
onChange({
|
onChange(undefined, undefined);
|
||||||
template: undefined,
|
|
||||||
type: undefined,
|
|
||||||
});
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const { id, type } = value;
|
const { templateId, type } = value;
|
||||||
if (!id || type === undefined) {
|
if (!templateId || type === undefined) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
onChange(getTemplate({ type, id: templateId }), type);
|
||||||
const template = getTemplate({ id, type });
|
|
||||||
onChange({ template, type } as SelectedTemplateValue);
|
|
||||||
}}
|
}}
|
||||||
options={options}
|
options={options}
|
||||||
data-cy="edge-stacks-create-template-selector"
|
data-cy="edge-stacks-create-template-selector"
|
||||||
|
@ -80,7 +79,8 @@ function useOptions() {
|
||||||
options:
|
options:
|
||||||
appTemplatesQuery.data?.map((template) => ({
|
appTemplatesQuery.data?.map((template) => ({
|
||||||
label: `${template.Title} - ${template.Description}`,
|
label: `${template.Title} - ${template.Description}`,
|
||||||
id: template.Id,
|
|
||||||
|
templateId: template.Id,
|
||||||
type: 'app' as 'app' | 'custom',
|
type: 'app' as 'app' | 'custom',
|
||||||
})) || [],
|
})) || [],
|
||||||
},
|
},
|
||||||
|
@ -90,14 +90,16 @@ function useOptions() {
|
||||||
customTemplatesQuery.data && customTemplatesQuery.data.length > 0
|
customTemplatesQuery.data && customTemplatesQuery.data.length > 0
|
||||||
? customTemplatesQuery.data.map((template) => ({
|
? customTemplatesQuery.data.map((template) => ({
|
||||||
label: `${template.Title} - ${template.Description}`,
|
label: `${template.Title} - ${template.Description}`,
|
||||||
id: template.Id,
|
|
||||||
|
templateId: template.Id,
|
||||||
type: 'custom' as 'app' | 'custom',
|
type: 'custom' as 'app' | 'custom',
|
||||||
}))
|
}))
|
||||||
: [
|
: [
|
||||||
{
|
{
|
||||||
label: 'No edge custom templates available',
|
label: 'No edge custom templates available',
|
||||||
id: 0,
|
|
||||||
type: 'custom' as 'app' | 'custom',
|
templateId: undefined,
|
||||||
|
type: undefined,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|
|
@ -1,14 +1,11 @@
|
||||||
import { VariablesFieldValue } from '@/react/portainer/custom-templates/components/CustomTemplatesVariablesField';
|
import { VariablesFieldValue } from '@/react/portainer/custom-templates/components/CustomTemplatesVariablesField';
|
||||||
import { TemplateViewModel } from '@/react/portainer/templates/app-templates/view-model';
|
|
||||||
import { CustomTemplate } from '@/react/portainer/templates/custom-templates/types';
|
|
||||||
|
|
||||||
export type SelectedTemplateValue =
|
export type SelectedTemplateValue =
|
||||||
| { template: CustomTemplate; type: 'custom' }
|
| { templateId: number; type: 'custom' }
|
||||||
| { template: TemplateViewModel; type: 'app' }
|
| { templateId: number; type: 'app' }
|
||||||
| { template: undefined; type: undefined };
|
| { templateId: undefined; type: undefined };
|
||||||
|
|
||||||
export type Values = {
|
export type Values = {
|
||||||
file?: string;
|
|
||||||
variables: VariablesFieldValue;
|
variables: VariablesFieldValue;
|
||||||
envVars: Record<string, string>;
|
envVars: Record<string, string>;
|
||||||
} & SelectedTemplateValue;
|
} & SelectedTemplateValue;
|
||||||
|
|
|
@ -1,27 +1,33 @@
|
||||||
import { mixed, object, SchemaOf, string } from 'yup';
|
import { mixed, number, object, SchemaOf } from 'yup';
|
||||||
|
|
||||||
import { variablesFieldValidation } from '@/react/portainer/custom-templates/components/CustomTemplatesVariablesField';
|
import { variablesFieldValidation } from '@/react/portainer/custom-templates/components/CustomTemplatesVariablesField';
|
||||||
import { VariableDefinition } from '@/react/portainer/custom-templates/components/CustomTemplatesVariablesDefinitionField';
|
import { VariableDefinition } from '@/react/portainer/custom-templates/components/CustomTemplatesVariablesDefinitionField';
|
||||||
import { envVarsFieldsetValidation } from '@/react/portainer/templates/app-templates/DeployFormWidget/EnvVarsFieldset';
|
import { envVarsFieldsetValidation } from '@/react/portainer/templates/app-templates/DeployFormWidget/EnvVarsFieldset';
|
||||||
import { TemplateEnv } from '@/react/portainer/templates/app-templates/types';
|
import { TemplateEnv } from '@/react/portainer/templates/app-templates/types';
|
||||||
|
|
||||||
function validation({
|
import { Values } from './types';
|
||||||
|
|
||||||
|
export function templateFieldsetValidation({
|
||||||
customVariablesDefinitions,
|
customVariablesDefinitions,
|
||||||
envVarDefinitions,
|
envVarDefinitions,
|
||||||
}: {
|
}: {
|
||||||
customVariablesDefinitions: VariableDefinition[];
|
customVariablesDefinitions: VariableDefinition[];
|
||||||
envVarDefinitions: Array<TemplateEnv>;
|
envVarDefinitions: Array<TemplateEnv>;
|
||||||
}) {
|
}): SchemaOf<Values> {
|
||||||
return object({
|
return object({
|
||||||
type: string().oneOf(['custom', 'app']).required(),
|
type: mixed<'app' | 'custom'>().oneOf(['custom', 'app']).optional(),
|
||||||
envVars: envVarsFieldsetValidation(envVarDefinitions)
|
envVars: envVarsFieldsetValidation(envVarDefinitions)
|
||||||
.optional()
|
.optional()
|
||||||
.when('type', {
|
.when('type', {
|
||||||
is: 'app',
|
is: 'app',
|
||||||
then: (schema: SchemaOf<unknown, never>) => schema.required(),
|
then: (schema: SchemaOf<unknown, never>) => schema.required(),
|
||||||
}),
|
}),
|
||||||
file: mixed().optional(),
|
templateId: mixed()
|
||||||
template: object().optional().default(null),
|
.optional()
|
||||||
|
.when('type', {
|
||||||
|
is: true,
|
||||||
|
then: () => number().required(),
|
||||||
|
}),
|
||||||
variables: variablesFieldValidation(customVariablesDefinitions)
|
variables: variablesFieldValidation(customVariablesDefinitions)
|
||||||
.optional()
|
.optional()
|
||||||
.when('type', {
|
.when('type', {
|
||||||
|
@ -30,5 +36,3 @@ function validation({
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export { validation as templateFieldsetValidation };
|
|
||||||
|
|
|
@ -0,0 +1,32 @@
|
||||||
|
import { TextTip } from '@@/Tip/TextTip';
|
||||||
|
import { SwitchField } from '@@/form-components/SwitchField';
|
||||||
|
|
||||||
|
export function WebhookSwitch({
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
}: {
|
||||||
|
value: boolean;
|
||||||
|
onChange: (value: boolean) => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="form-section-title"> Webhooks </div>
|
||||||
|
<SwitchField
|
||||||
|
label="Create an Edge stack webhook"
|
||||||
|
checked={value}
|
||||||
|
onChange={onChange}
|
||||||
|
tooltip="Create a webhook (or callback URI) to automate the update of this stack. Sending a POST request to this callback URI (without requiring any authentication) will pull the most up-to-date version of the associated image and re-deploy this stack."
|
||||||
|
labelClass="col-sm-3 col-lg-2"
|
||||||
|
data-cy="webhook-switch"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{value && (
|
||||||
|
<TextTip>
|
||||||
|
Sending environment variables to the webhook is updating the stack
|
||||||
|
with the new values. New variables names will be added to the stack
|
||||||
|
and existing variables will be updated.
|
||||||
|
</TextTip>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
|
@ -0,0 +1,37 @@
|
||||||
|
import { RegistryId } from '@/react/portainer/registries/types/registry';
|
||||||
|
import {
|
||||||
|
GitFormModel,
|
||||||
|
RelativePathModel,
|
||||||
|
} from '@/react/portainer/gitops/types';
|
||||||
|
|
||||||
|
import { EnvVarValues } from '@@/form-components/EnvironmentVariablesFieldset';
|
||||||
|
|
||||||
|
import { EdgeGroup } from '../../edge-groups/types';
|
||||||
|
import { DeploymentType, StaggerConfig } from '../types';
|
||||||
|
|
||||||
|
import { KubeFormValues } from './KubeManifestForm';
|
||||||
|
import { Values as TemplateFieldsetValues } from './TemplateFieldset/types';
|
||||||
|
|
||||||
|
export type Method = 'editor' | 'upload' | 'repository' | 'template';
|
||||||
|
|
||||||
|
export interface DockerFormValues {
|
||||||
|
method: Method;
|
||||||
|
fileContent: string;
|
||||||
|
file?: File;
|
||||||
|
templateValues: TemplateFieldsetValues;
|
||||||
|
git: GitFormModel;
|
||||||
|
relativePath: RelativePathModel;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FormValues extends KubeFormValues, DockerFormValues {
|
||||||
|
method: Method;
|
||||||
|
name: string;
|
||||||
|
groupIds: Array<EdgeGroup['Id']>;
|
||||||
|
deploymentType: DeploymentType;
|
||||||
|
envVars: EnvVarValues;
|
||||||
|
privateRegistryId: RegistryId;
|
||||||
|
prePullImage: boolean;
|
||||||
|
retryDeploy: boolean;
|
||||||
|
enableWebhook: boolean;
|
||||||
|
staggerConfig: StaggerConfig;
|
||||||
|
}
|
|
@ -0,0 +1,169 @@
|
||||||
|
import { useRouter } from '@uirouter/react';
|
||||||
|
|
||||||
|
import { useCurrentUser } from '@/react/hooks/useUser';
|
||||||
|
import { useAnalytics } from '@/react/hooks/useAnalytics';
|
||||||
|
import { TemplateViewModel } from '@/react/portainer/templates/app-templates/view-model';
|
||||||
|
import { CustomTemplate } from '@/react/portainer/templates/custom-templates/types';
|
||||||
|
|
||||||
|
import {
|
||||||
|
BasePayload,
|
||||||
|
CreateEdgeStackPayload,
|
||||||
|
useCreateEdgeStack,
|
||||||
|
} from '../queries/useCreateEdgeStack/useCreateEdgeStack';
|
||||||
|
import { DeploymentType } from '../types';
|
||||||
|
|
||||||
|
import { FormValues, Method } from './types';
|
||||||
|
|
||||||
|
export function useCreate({
|
||||||
|
webhookId,
|
||||||
|
template,
|
||||||
|
templateType,
|
||||||
|
}: {
|
||||||
|
webhookId: string;
|
||||||
|
template: TemplateViewModel | CustomTemplate | undefined;
|
||||||
|
templateType: 'app' | 'custom' | undefined;
|
||||||
|
}) {
|
||||||
|
const router = useRouter();
|
||||||
|
const mutation = useCreateEdgeStack();
|
||||||
|
const { user } = useCurrentUser();
|
||||||
|
const { trackEvent } = useAnalytics();
|
||||||
|
|
||||||
|
return {
|
||||||
|
isLoading: mutation.isLoading,
|
||||||
|
onSubmit: handleSubmit,
|
||||||
|
};
|
||||||
|
|
||||||
|
function handleSubmit(values: FormValues) {
|
||||||
|
const method = getMethod(
|
||||||
|
values.method,
|
||||||
|
getIsGitTemplate(template, templateType)
|
||||||
|
);
|
||||||
|
trackEvent('edge-stack-creation', {
|
||||||
|
category: 'edge',
|
||||||
|
metadata: buildAnalyticsMetadata(
|
||||||
|
values.method,
|
||||||
|
values.deploymentType,
|
||||||
|
template?.Title
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
|
mutation.mutate(getPayload(method, values), {
|
||||||
|
onSuccess: () => {
|
||||||
|
router.stateService.go('^');
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
function getPayload(
|
||||||
|
method: 'string' | 'file' | 'git',
|
||||||
|
values: FormValues
|
||||||
|
): CreateEdgeStackPayload {
|
||||||
|
switch (method) {
|
||||||
|
case 'file':
|
||||||
|
if (!values.file) {
|
||||||
|
throw new Error('File is required');
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
method: 'file',
|
||||||
|
payload: {
|
||||||
|
...getBasePayload(values),
|
||||||
|
file: values.file,
|
||||||
|
webhook: values.enableWebhook ? webhookId : undefined,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
case 'string':
|
||||||
|
return {
|
||||||
|
method: 'string',
|
||||||
|
payload: {
|
||||||
|
...getBasePayload(values),
|
||||||
|
fileContent: values.fileContent,
|
||||||
|
webhook: values.enableWebhook ? webhookId : undefined,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
case 'git':
|
||||||
|
return {
|
||||||
|
method: 'git',
|
||||||
|
payload: {
|
||||||
|
...getBasePayload(values),
|
||||||
|
git: values.git,
|
||||||
|
relativePathSettings: values.relativePath,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
default:
|
||||||
|
throw new Error(`Unknown method: ${method}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getBasePayload(values: FormValues): BasePayload {
|
||||||
|
return {
|
||||||
|
userId: user.Id,
|
||||||
|
deploymentType: values.deploymentType,
|
||||||
|
edgeGroups: values.groupIds,
|
||||||
|
name: values.name,
|
||||||
|
envVars: values.envVars,
|
||||||
|
registries: values.privateRegistryId ? [values.privateRegistryId] : [],
|
||||||
|
prePullImage: values.prePullImage,
|
||||||
|
retryDeploy: values.retryDeploy,
|
||||||
|
staggerConfig: values.staggerConfig,
|
||||||
|
useManifestNamespaces: values.useManifestNamespaces,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildAnalyticsMetadata(
|
||||||
|
method: Method,
|
||||||
|
type: DeploymentType,
|
||||||
|
templateTitle: string | undefined
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
type: methodLabel(method),
|
||||||
|
format: type === DeploymentType.Compose ? 'compose' : 'kubernetes',
|
||||||
|
templateName: templateTitle,
|
||||||
|
};
|
||||||
|
|
||||||
|
function methodLabel(method: Method) {
|
||||||
|
switch (method) {
|
||||||
|
case 'repository':
|
||||||
|
return 'git';
|
||||||
|
case 'upload':
|
||||||
|
return 'file-upload';
|
||||||
|
case 'template':
|
||||||
|
return 'template';
|
||||||
|
case 'editor':
|
||||||
|
default:
|
||||||
|
return 'web-editor';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getMethod(
|
||||||
|
method: 'template' | 'repository' | 'editor' | 'upload',
|
||||||
|
isGitTemplate: boolean
|
||||||
|
): 'string' | 'file' | 'git' {
|
||||||
|
switch (method) {
|
||||||
|
case 'upload':
|
||||||
|
return 'file';
|
||||||
|
case 'repository':
|
||||||
|
return 'git';
|
||||||
|
case 'template':
|
||||||
|
if (isGitTemplate) {
|
||||||
|
return 'git';
|
||||||
|
}
|
||||||
|
return 'string';
|
||||||
|
case 'editor':
|
||||||
|
default:
|
||||||
|
return 'string';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getIsGitTemplate(
|
||||||
|
template: TemplateViewModel | CustomTemplate | undefined,
|
||||||
|
templateType: 'app' | 'custom' | undefined
|
||||||
|
) {
|
||||||
|
if (templateType === 'app') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return !!template && !!(template as CustomTemplate).GitConfig;
|
||||||
|
}
|
|
@ -0,0 +1,94 @@
|
||||||
|
import { SetStateAction, useEffect, useState } from 'react';
|
||||||
|
|
||||||
|
import { renderTemplate } from '@/react/portainer/custom-templates/components/utils';
|
||||||
|
import { useCustomTemplateFile } from '@/react/portainer/templates/custom-templates/queries/useCustomTemplateFile';
|
||||||
|
import { useCustomTemplate } from '@/react/portainer/templates/custom-templates/queries/useCustomTemplate';
|
||||||
|
import { CustomTemplate } from '@/react/portainer/templates/custom-templates/types';
|
||||||
|
import { StackType } from '@/react/common/stacks/types';
|
||||||
|
import { toGitFormModel } from '@/react/portainer/gitops/types';
|
||||||
|
|
||||||
|
import { DeploymentType } from '../types';
|
||||||
|
import { getDefaultStaggerConfig } from '../components/StaggerFieldset.types';
|
||||||
|
|
||||||
|
import { DockerFormValues, FormValues } from './types';
|
||||||
|
|
||||||
|
export function useRenderTemplate(
|
||||||
|
templateValues: DockerFormValues['templateValues'],
|
||||||
|
setValues: (values: SetStateAction<DockerFormValues>) => void
|
||||||
|
) {
|
||||||
|
const templateQuery = useCustomTemplate(templateValues.templateId);
|
||||||
|
|
||||||
|
const template = templateQuery.data;
|
||||||
|
|
||||||
|
const templateFileQuery = useCustomTemplateFile(
|
||||||
|
templateValues.templateId,
|
||||||
|
!!template?.GitConfig
|
||||||
|
);
|
||||||
|
const [renderedFile, setRenderedFile] = useState<string>('');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (templateFileQuery.data) {
|
||||||
|
const newFile = renderTemplate(
|
||||||
|
templateFileQuery.data,
|
||||||
|
templateValues.variables,
|
||||||
|
template?.Variables || []
|
||||||
|
);
|
||||||
|
|
||||||
|
if (newFile !== renderedFile) {
|
||||||
|
setRenderedFile(newFile);
|
||||||
|
setValues((values) => ({
|
||||||
|
...values,
|
||||||
|
fileContent: newFile,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [
|
||||||
|
renderedFile,
|
||||||
|
setValues,
|
||||||
|
template,
|
||||||
|
templateFileQuery.data,
|
||||||
|
templateValues.variables,
|
||||||
|
]);
|
||||||
|
|
||||||
|
const [currentTemplateId, setCurrentTemplateId] = useState<
|
||||||
|
number | undefined
|
||||||
|
>(templateValues.templateId);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (template?.Id !== currentTemplateId) {
|
||||||
|
setCurrentTemplateId(template?.Id);
|
||||||
|
setValues((values) => ({
|
||||||
|
...values,
|
||||||
|
...getValuesFromTemplate(template),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}, [currentTemplateId, setValues, template]);
|
||||||
|
|
||||||
|
return template;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getValuesFromTemplate(
|
||||||
|
template: CustomTemplate | undefined
|
||||||
|
): Partial<FormValues> {
|
||||||
|
if (!template) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
deploymentType:
|
||||||
|
template.Type === StackType.Kubernetes
|
||||||
|
? DeploymentType.Kubernetes
|
||||||
|
: DeploymentType.Compose,
|
||||||
|
git: toGitFormModel(template.GitConfig),
|
||||||
|
...(template.EdgeSettings
|
||||||
|
? {
|
||||||
|
prePullImage: template.EdgeSettings.PrePullImage || false,
|
||||||
|
retryDeploy: template.EdgeSettings.RetryDeploy || false,
|
||||||
|
privateRegistryId: template.EdgeSettings.PrivateRegistryId,
|
||||||
|
staggerConfig:
|
||||||
|
template.EdgeSettings.StaggerConfig || getDefaultStaggerConfig(),
|
||||||
|
...template.EdgeSettings.RelativePathSettings,
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
|
};
|
||||||
|
}
|
|
@ -0,0 +1,37 @@
|
||||||
|
import { useParamState } from '@/react/hooks/useParamState';
|
||||||
|
|
||||||
|
export function useTemplateParams() {
|
||||||
|
const [id, setTemplateId] = useParamState('templateId', (param) => {
|
||||||
|
if (!param) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const templateId = parseInt(param, 10);
|
||||||
|
if (Number.isNaN(templateId)) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
return templateId;
|
||||||
|
});
|
||||||
|
|
||||||
|
const [type, setTemplateType] = useParamState('templateType', (param) => {
|
||||||
|
if (param === 'app' || param === 'custom') {
|
||||||
|
return param;
|
||||||
|
}
|
||||||
|
|
||||||
|
return undefined;
|
||||||
|
});
|
||||||
|
|
||||||
|
return [{ id, type }, handleChange] as const;
|
||||||
|
|
||||||
|
function handleChange({
|
||||||
|
id,
|
||||||
|
type,
|
||||||
|
}: {
|
||||||
|
id: number | undefined;
|
||||||
|
type: 'app' | 'custom' | undefined;
|
||||||
|
}) {
|
||||||
|
setTemplateId(id);
|
||||||
|
setTemplateType(type);
|
||||||
|
}
|
||||||
|
}
|
|
@ -252,7 +252,13 @@ function InnerForm({
|
||||||
errors={errors.authentication}
|
errors={errors.authentication}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{isBE && <RelativePathFieldset value={values.relativePath} isEditing />}
|
{isBE && (
|
||||||
|
<RelativePathFieldset
|
||||||
|
values={values.relativePath}
|
||||||
|
isEditing
|
||||||
|
onChange={() => {}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
<EnvironmentVariablesPanel
|
<EnvironmentVariablesPanel
|
||||||
onChange={(value) => setFieldValue('envVars', value)}
|
onChange={(value) => setFieldValue('envVars', value)}
|
||||||
|
|
|
@ -1,12 +1,13 @@
|
||||||
import _ from 'lodash';
|
import _ from 'lodash';
|
||||||
|
import { useState } from 'react';
|
||||||
|
|
||||||
import { EdgeGroup } from '@/react/edge/edge-groups/types';
|
import { EdgeGroup } from '@/react/edge/edge-groups/types';
|
||||||
|
|
||||||
import { Select } from '@@/form-components/ReactSelect';
|
import { Select } from '@@/form-components/ReactSelect';
|
||||||
import { FormSection } from '@@/form-components/FormSection';
|
|
||||||
import { FormError } from '@@/form-components/FormError';
|
import { FormError } from '@@/form-components/FormError';
|
||||||
import { Link } from '@@/Link';
|
import { Link } from '@@/Link';
|
||||||
import { FormControl } from '@@/form-components/FormControl';
|
import { FormControl } from '@@/form-components/FormControl';
|
||||||
|
import { FormSection } from '@@/form-components/FormSection';
|
||||||
|
|
||||||
import { useEdgeGroups } from '../../edge-groups/queries/useEdgeGroups';
|
import { useEdgeGroups } from '../../edge-groups/queries/useEdgeGroups';
|
||||||
|
|
||||||
|
@ -29,20 +30,28 @@ export function EdgeGroupsSelector({
|
||||||
isGroupVisible = () => true,
|
isGroupVisible = () => true,
|
||||||
required,
|
required,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
|
const [inputId] = useState(() => _.uniqueId('edge-groups-selector-'));
|
||||||
|
|
||||||
const selector = (
|
const selector = (
|
||||||
<InnerSelector
|
<InnerSelector
|
||||||
value={value}
|
value={value}
|
||||||
onChange={onChange}
|
onChange={onChange}
|
||||||
isGroupVisible={isGroupVisible}
|
isGroupVisible={isGroupVisible}
|
||||||
|
inputId={inputId}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
||||||
return horizontal ? (
|
return horizontal ? (
|
||||||
<FormControl errors={error} label="Edge Groups" required={required}>
|
<FormControl
|
||||||
|
errors={error}
|
||||||
|
label="Edge Groups"
|
||||||
|
required={required}
|
||||||
|
inputId={inputId}
|
||||||
|
>
|
||||||
{selector}
|
{selector}
|
||||||
</FormControl>
|
</FormControl>
|
||||||
) : (
|
) : (
|
||||||
<FormSection title={`Edge Groups${required ? ' *' : ''}`}>
|
<FormSection title={`Edge Groups${required ? ' *' : ''}`} htmlFor={inputId}>
|
||||||
<div className="form-group">
|
<div className="form-group">
|
||||||
<div className="col-sm-12">{selector} </div>
|
<div className="col-sm-12">{selector} </div>
|
||||||
{error && (
|
{error && (
|
||||||
|
@ -59,10 +68,12 @@ function InnerSelector({
|
||||||
value,
|
value,
|
||||||
onChange,
|
onChange,
|
||||||
isGroupVisible,
|
isGroupVisible,
|
||||||
|
inputId,
|
||||||
}: {
|
}: {
|
||||||
isGroupVisible(group: EdgeGroup): boolean;
|
isGroupVisible(group: EdgeGroup): boolean;
|
||||||
value: SingleValue[];
|
value: SingleValue[];
|
||||||
onChange: (value: SingleValue[]) => void;
|
onChange: (value: SingleValue[]) => void;
|
||||||
|
inputId: string;
|
||||||
}) {
|
}) {
|
||||||
const edgeGroupsQuery = useEdgeGroups();
|
const edgeGroupsQuery = useEdgeGroups();
|
||||||
|
|
||||||
|
@ -86,6 +97,7 @@ function InnerSelector({
|
||||||
placeholder="Select one or multiple group(s)"
|
placeholder="Select one or multiple group(s)"
|
||||||
closeMenuOnSelect={false}
|
closeMenuOnSelect={false}
|
||||||
data-cy="edge-stacks-groups-selector"
|
data-cy="edge-stacks-groups-selector"
|
||||||
|
inputId={inputId}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div className="small text-muted">
|
<div className="small text-muted">
|
||||||
|
|
|
@ -1,6 +1,4 @@
|
||||||
import _ from 'lodash';
|
import { DeploymentType } from '@/react/edge/edge-stacks/types';
|
||||||
|
|
||||||
import { EditorType } from '@/react/edge/edge-stacks/types';
|
|
||||||
|
|
||||||
import { BoxSelector } from '@@/BoxSelector';
|
import { BoxSelector } from '@@/BoxSelector';
|
||||||
import { BoxSelectorOption } from '@@/BoxSelector/types';
|
import { BoxSelectorOption } from '@@/BoxSelector/types';
|
||||||
|
@ -10,8 +8,8 @@ import {
|
||||||
} from '@@/BoxSelector/common-options/deployment-methods';
|
} from '@@/BoxSelector/common-options/deployment-methods';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
value: number;
|
value: DeploymentType;
|
||||||
onChange(value: number): void;
|
onChange(value: DeploymentType): void;
|
||||||
hasDockerEndpoint: boolean;
|
hasDockerEndpoint: boolean;
|
||||||
hasKubeEndpoint: boolean;
|
hasKubeEndpoint: boolean;
|
||||||
allowKubeToSelectCompose?: boolean;
|
allowKubeToSelectCompose?: boolean;
|
||||||
|
@ -24,10 +22,10 @@ export function EdgeStackDeploymentTypeSelector({
|
||||||
hasKubeEndpoint,
|
hasKubeEndpoint,
|
||||||
allowKubeToSelectCompose,
|
allowKubeToSelectCompose,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
const deploymentOptions: BoxSelectorOption<number>[] = _.compact([
|
const deploymentOptions: BoxSelectorOption<DeploymentType>[] = [
|
||||||
{
|
{
|
||||||
...compose,
|
...compose,
|
||||||
value: EditorType.Compose,
|
value: DeploymentType.Compose,
|
||||||
disabled: () => !allowKubeToSelectCompose && hasKubeEndpoint,
|
disabled: () => !allowKubeToSelectCompose && hasKubeEndpoint,
|
||||||
tooltip: () =>
|
tooltip: () =>
|
||||||
hasKubeEndpoint
|
hasKubeEndpoint
|
||||||
|
@ -36,7 +34,7 @@ export function EdgeStackDeploymentTypeSelector({
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
...kubernetes,
|
...kubernetes,
|
||||||
value: EditorType.Kubernetes,
|
value: DeploymentType.Kubernetes,
|
||||||
disabled: () => hasDockerEndpoint,
|
disabled: () => hasDockerEndpoint,
|
||||||
tooltip: () =>
|
tooltip: () =>
|
||||||
hasDockerEndpoint
|
hasDockerEndpoint
|
||||||
|
@ -44,7 +42,7 @@ export function EdgeStackDeploymentTypeSelector({
|
||||||
: '',
|
: '',
|
||||||
iconType: 'logo',
|
iconType: 'logo',
|
||||||
},
|
},
|
||||||
]);
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
|
|
@ -0,0 +1,309 @@
|
||||||
|
import { number, string, object, SchemaOf } from 'yup';
|
||||||
|
import { FormikErrors } from 'formik';
|
||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
|
||||||
|
import { FormSection } from '@@/form-components/FormSection';
|
||||||
|
import { RadioGroup } from '@@/RadioGroup/RadioGroup';
|
||||||
|
import { Input } from '@@/form-components/Input';
|
||||||
|
import { TextTip } from '@@/Tip/TextTip';
|
||||||
|
import { FormControl } from '@@/form-components/FormControl';
|
||||||
|
import { Button, ButtonGroup } from '@@/buttons';
|
||||||
|
|
||||||
|
import { StaggerParallelFieldset } from './StaggerParallelFieldset';
|
||||||
|
import {
|
||||||
|
StaggerConfig,
|
||||||
|
StaggerOption,
|
||||||
|
StaggerParallelOption,
|
||||||
|
UpdateFailureAction,
|
||||||
|
} from './StaggerFieldset.types';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
values: StaggerConfig;
|
||||||
|
onChange: (value: Partial<StaggerConfig>) => void;
|
||||||
|
errors?: FormikErrors<StaggerConfig>;
|
||||||
|
isEdit?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const staggerOptions = [
|
||||||
|
{
|
||||||
|
value: StaggerOption.AllAtOnce,
|
||||||
|
label: 'All edge devices at once',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: StaggerOption.Parallel,
|
||||||
|
label: 'Parallel edge device(s)',
|
||||||
|
},
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export function StaggerFieldset({
|
||||||
|
values: initialValue,
|
||||||
|
onChange,
|
||||||
|
errors,
|
||||||
|
isEdit = true,
|
||||||
|
}: Props) {
|
||||||
|
const [values, setControlledValues] = useState(initialValue); // TODO: remove this state when form is not inside angularjs
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!!initialValue && initialValue.StaggerOption !== values.StaggerOption) {
|
||||||
|
setControlledValues(initialValue);
|
||||||
|
}
|
||||||
|
}, [initialValue, values]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<FormSection title="Update configurations">
|
||||||
|
{!isEdit && (
|
||||||
|
<div className="form-group">
|
||||||
|
<div className="col-sm-12">
|
||||||
|
<TextTip color="blue">
|
||||||
|
Please note that the 'Update Configuration' setting
|
||||||
|
takes effect exclusively during edge stack updates, whether
|
||||||
|
triggered manually, through webhook events, or via GitOps updates
|
||||||
|
processes
|
||||||
|
</TextTip>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="form-group">
|
||||||
|
<div className="col-sm-12">
|
||||||
|
<RadioGroup
|
||||||
|
options={staggerOptions}
|
||||||
|
selectedOption={values.StaggerOption}
|
||||||
|
onOptionChange={(value) => {
|
||||||
|
handleChange({ StaggerOption: value });
|
||||||
|
}}
|
||||||
|
name="StaggerOption"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{values.StaggerOption === StaggerOption.Parallel && (
|
||||||
|
<div className="mb-2">
|
||||||
|
<TextTip color="blue">
|
||||||
|
Specify the number of device(s) to be updated concurrently.
|
||||||
|
{values.StaggerParallelOption ===
|
||||||
|
StaggerParallelOption.Incremental && (
|
||||||
|
<div className="mb-2">
|
||||||
|
For example, if you start with 2 devices and multiply by 5, the
|
||||||
|
update will initially cover 2 edge devices, then 10 devices (2 x
|
||||||
|
5), followed by 50 devices (10 x 5), and so on.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</TextTip>
|
||||||
|
|
||||||
|
<StaggerParallelFieldset
|
||||||
|
values={values}
|
||||||
|
onChange={handleChange}
|
||||||
|
errors={errors}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormControl
|
||||||
|
label="Timeout"
|
||||||
|
inputId="timeout"
|
||||||
|
errors={errors?.Timeout}
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<div style={{ display: 'inline-block', width: '150px' }}>
|
||||||
|
<Input
|
||||||
|
name="Timeout"
|
||||||
|
id="stagger-timeout"
|
||||||
|
placeholder="eg. 5 (optional)"
|
||||||
|
value={values.Timeout}
|
||||||
|
onChange={(e) =>
|
||||||
|
handleChange({
|
||||||
|
Timeout: e.currentTarget.value,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
data-cy="edge-stacks-stagger-timeout-input"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<span> {' minute(s) '} </span>
|
||||||
|
</div>
|
||||||
|
</FormControl>
|
||||||
|
|
||||||
|
<FormControl
|
||||||
|
label="Update delay"
|
||||||
|
inputId="update-delay"
|
||||||
|
errors={errors?.UpdateDelay}
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<div style={{ display: 'inline-block', width: '150px' }}>
|
||||||
|
<Input
|
||||||
|
name="UpdateDelay"
|
||||||
|
data-cy="edge-stacks-stagger-update-delay-input"
|
||||||
|
id="stagger-update-delay"
|
||||||
|
placeholder="eg. 5 (optional)"
|
||||||
|
value={values.UpdateDelay}
|
||||||
|
onChange={(e) =>
|
||||||
|
handleChange({
|
||||||
|
UpdateDelay: e.currentTarget.value,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<span> {' minute(s) '} </span>
|
||||||
|
</div>
|
||||||
|
</FormControl>
|
||||||
|
|
||||||
|
<FormControl
|
||||||
|
label="Update failure action"
|
||||||
|
inputId="update-failure-action"
|
||||||
|
errors={errors?.UpdateFailureAction}
|
||||||
|
>
|
||||||
|
<ButtonGroup>
|
||||||
|
<Button
|
||||||
|
className="btn-box-shadow"
|
||||||
|
data-cy="edge-stacks-stagger-update-failure-action-continue-button"
|
||||||
|
color={
|
||||||
|
values.UpdateFailureAction === UpdateFailureAction.Continue
|
||||||
|
? 'primary'
|
||||||
|
: 'light'
|
||||||
|
}
|
||||||
|
onClick={() =>
|
||||||
|
handleChange({
|
||||||
|
UpdateFailureAction: UpdateFailureAction.Continue,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Continue
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
className="btn-box-shadow"
|
||||||
|
data-cy="edge-stacks-stagger-update-failure-action-pause-button"
|
||||||
|
color={
|
||||||
|
values.UpdateFailureAction === UpdateFailureAction.Pause
|
||||||
|
? 'primary'
|
||||||
|
: 'light'
|
||||||
|
}
|
||||||
|
onClick={() =>
|
||||||
|
handleChange({
|
||||||
|
UpdateFailureAction: UpdateFailureAction.Pause,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Pause
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
className="btn-box-shadow"
|
||||||
|
data-cy="edge-stacks-stagger-update-failure-action-rollback-button"
|
||||||
|
color={
|
||||||
|
values.UpdateFailureAction === UpdateFailureAction.Rollback
|
||||||
|
? 'primary'
|
||||||
|
: 'light'
|
||||||
|
}
|
||||||
|
onClick={() =>
|
||||||
|
handleChange({
|
||||||
|
UpdateFailureAction: UpdateFailureAction.Rollback,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Rollback
|
||||||
|
</Button>
|
||||||
|
</ButtonGroup>
|
||||||
|
</FormControl>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</FormSection>
|
||||||
|
);
|
||||||
|
|
||||||
|
function handleChange(partialValue: Partial<StaggerConfig>) {
|
||||||
|
onChange(partialValue);
|
||||||
|
setControlledValues((values) => ({ ...values, ...partialValue }));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function staggerConfigValidation(): SchemaOf<StaggerConfig> {
|
||||||
|
return object({
|
||||||
|
StaggerOption: number()
|
||||||
|
.oneOf([StaggerOption.AllAtOnce, StaggerOption.Parallel])
|
||||||
|
.required('Stagger option is required'),
|
||||||
|
StaggerParallelOption: number()
|
||||||
|
.when('StaggerOption', {
|
||||||
|
is: StaggerOption.Parallel,
|
||||||
|
then: (schema) =>
|
||||||
|
schema.oneOf([
|
||||||
|
StaggerParallelOption.Fixed,
|
||||||
|
StaggerParallelOption.Incremental,
|
||||||
|
]),
|
||||||
|
})
|
||||||
|
.optional(),
|
||||||
|
DeviceNumber: number()
|
||||||
|
.default(0)
|
||||||
|
.when('StaggerOption', {
|
||||||
|
is: StaggerOption.Parallel,
|
||||||
|
then: (schema) =>
|
||||||
|
schema.when('StaggerParallelOption', {
|
||||||
|
is: StaggerParallelOption.Fixed,
|
||||||
|
then: (schema) =>
|
||||||
|
schema
|
||||||
|
.required('Devices number is at least 1')
|
||||||
|
.min(1, 'Devices number is at least 1'),
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
.optional(),
|
||||||
|
DeviceNumberStartFrom: number()
|
||||||
|
.when('StaggerOption', {
|
||||||
|
is: StaggerOption.Parallel,
|
||||||
|
then: (schema) =>
|
||||||
|
schema.when('StaggerParallelOption', {
|
||||||
|
is: StaggerParallelOption.Incremental,
|
||||||
|
then: (schema) =>
|
||||||
|
schema
|
||||||
|
.min(1, 'Devices number start from at least 1')
|
||||||
|
.required('Devices number is required'),
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
.optional(),
|
||||||
|
DeviceNumberIncrementBy: number()
|
||||||
|
.default(2)
|
||||||
|
.when('StaggerOption', {
|
||||||
|
is: StaggerOption.Parallel,
|
||||||
|
then: (schema) =>
|
||||||
|
schema.when('StaggerParallelOption', {
|
||||||
|
is: StaggerParallelOption.Incremental,
|
||||||
|
then: (schema) =>
|
||||||
|
schema
|
||||||
|
.min(2)
|
||||||
|
.max(10)
|
||||||
|
.required('Devices number increment by is required'),
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
.optional(),
|
||||||
|
Timeout: string()
|
||||||
|
.default('')
|
||||||
|
.when('StaggerOption', {
|
||||||
|
is: StaggerOption.Parallel,
|
||||||
|
then: (schema) =>
|
||||||
|
schema.test(
|
||||||
|
'is-number',
|
||||||
|
'Timeout must be a number',
|
||||||
|
(value) => !Number.isNaN(Number(value))
|
||||||
|
),
|
||||||
|
})
|
||||||
|
.optional(),
|
||||||
|
UpdateDelay: string()
|
||||||
|
.default('')
|
||||||
|
.when('StaggerOption', {
|
||||||
|
is: StaggerOption.Parallel,
|
||||||
|
then: (schema) =>
|
||||||
|
schema.test(
|
||||||
|
'is-number',
|
||||||
|
'Timeout must be a number',
|
||||||
|
(value) => !Number.isNaN(Number(value))
|
||||||
|
),
|
||||||
|
})
|
||||||
|
.optional(),
|
||||||
|
UpdateFailureAction: number()
|
||||||
|
.default(UpdateFailureAction.Continue)
|
||||||
|
.when('StaggerOption', {
|
||||||
|
is: StaggerOption.Parallel,
|
||||||
|
then: (schema) =>
|
||||||
|
schema.oneOf([
|
||||||
|
UpdateFailureAction.Continue,
|
||||||
|
UpdateFailureAction.Pause,
|
||||||
|
UpdateFailureAction.Rollback,
|
||||||
|
]),
|
||||||
|
})
|
||||||
|
.optional(),
|
||||||
|
});
|
||||||
|
}
|
|
@ -0,0 +1,130 @@
|
||||||
|
import { FormikErrors } from 'formik';
|
||||||
|
|
||||||
|
import { Select, Input } from '@@/form-components/Input';
|
||||||
|
import { FormError } from '@@/form-components/FormError';
|
||||||
|
|
||||||
|
import { StaggerConfig, StaggerParallelOption } from './StaggerFieldset.types';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
values: StaggerConfig;
|
||||||
|
onChange: (value: Partial<StaggerConfig>) => void;
|
||||||
|
errors?: FormikErrors<StaggerConfig>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function StaggerParallelFieldset({ values, onChange, errors }: Props) {
|
||||||
|
const staggerParallelOptions = [
|
||||||
|
{
|
||||||
|
value: StaggerParallelOption.Fixed.toString(),
|
||||||
|
label: 'Number of device(s)',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: StaggerParallelOption.Incremental.toString(),
|
||||||
|
label: 'Exponential rollout',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const deviceNumberIncrementBy = [
|
||||||
|
{
|
||||||
|
value: '2',
|
||||||
|
label: '2',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: '5',
|
||||||
|
label: '5',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: '10',
|
||||||
|
label: '10',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className='form-group mb-5 mt-2 after:clear-both after:table after:content-[""]' // to fix issues with float"
|
||||||
|
>
|
||||||
|
<div className="col-sm-3 col-lg-2">
|
||||||
|
<Select
|
||||||
|
id="stagger-parallel-option"
|
||||||
|
data-cy="edge-stack-stagger-parallel-option-select"
|
||||||
|
value={values.StaggerParallelOption?.toString()}
|
||||||
|
onChange={(e) =>
|
||||||
|
handleChange({
|
||||||
|
StaggerParallelOption: parseInt(e.currentTarget.value, 10),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
options={staggerParallelOptions}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{values.StaggerParallelOption === StaggerParallelOption.Fixed && (
|
||||||
|
<div className="col-sm-9 col-lg-10">
|
||||||
|
<Input
|
||||||
|
name="DeviceNumber"
|
||||||
|
data-cy="edge-stack-device-number-input"
|
||||||
|
id="device-number"
|
||||||
|
type="number"
|
||||||
|
placeholder="eg. 1 or 10"
|
||||||
|
min={1}
|
||||||
|
value={values.DeviceNumber || ''}
|
||||||
|
onChange={(e) => {
|
||||||
|
handleChange({
|
||||||
|
DeviceNumber: e.currentTarget.valueAsNumber || undefined,
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{errors?.DeviceNumber && (
|
||||||
|
<FormError>{errors?.DeviceNumber}</FormError>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{values.StaggerParallelOption === StaggerParallelOption.Incremental && (
|
||||||
|
<div className="col-sm-9 col-lg-10">
|
||||||
|
<div>
|
||||||
|
<span> {' start with '} </span>
|
||||||
|
<div style={{ display: 'inline-block', width: '150px' }}>
|
||||||
|
<Input
|
||||||
|
name="DeviceNumberStartFrom"
|
||||||
|
data-cy="edge-stack-device-number-start-from-input"
|
||||||
|
type="number"
|
||||||
|
id="device-number-start-from"
|
||||||
|
min={1}
|
||||||
|
placeholder="eg. 1"
|
||||||
|
value={values.DeviceNumberStartFrom}
|
||||||
|
onChange={(e) =>
|
||||||
|
handleChange({
|
||||||
|
DeviceNumberStartFrom:
|
||||||
|
e.currentTarget.value !== ''
|
||||||
|
? e.currentTarget.valueAsNumber
|
||||||
|
: 0,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<span> {' device(s) and multiply the group size by '} </span>
|
||||||
|
<Select
|
||||||
|
id="device-number-incremental"
|
||||||
|
data-cy="edge-stack-device-number-incremental-select"
|
||||||
|
value={values.DeviceNumberIncrementBy}
|
||||||
|
style={{ display: 'inline-block', width: '150px' }}
|
||||||
|
onChange={(e) =>
|
||||||
|
handleChange({
|
||||||
|
DeviceNumberIncrementBy: parseInt(e.currentTarget.value, 10),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
options={deviceNumberIncrementBy}
|
||||||
|
/>
|
||||||
|
<span>{' for each rollout '} </span>
|
||||||
|
</div>
|
||||||
|
{errors?.DeviceNumberStartFrom && (
|
||||||
|
<FormError>{errors?.DeviceNumberStartFrom}</FormError>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
function handleChange(partialValue: Partial<StaggerConfig>) {
|
||||||
|
onChange(partialValue);
|
||||||
|
}
|
||||||
|
}
|
|
@ -7,6 +7,8 @@ import {
|
||||||
GitFormModel,
|
GitFormModel,
|
||||||
RelativePathModel,
|
RelativePathModel,
|
||||||
} from '@/react/portainer/gitops/types';
|
} from '@/react/portainer/gitops/types';
|
||||||
|
import { saveGitCredentialsIfNeeded } from '@/react/portainer/account/git-credentials/queries/useCreateGitCredentialsMutation';
|
||||||
|
import { UserId } from '@/portainer/users/types';
|
||||||
|
|
||||||
import { DeploymentType, StaggerConfig } from '../../types';
|
import { DeploymentType, StaggerConfig } from '../../types';
|
||||||
|
|
||||||
|
@ -18,7 +20,8 @@ export function useCreateEdgeStack() {
|
||||||
return useMutation(createEdgeStack);
|
return useMutation(createEdgeStack);
|
||||||
}
|
}
|
||||||
|
|
||||||
type BasePayload = {
|
export type BasePayload = {
|
||||||
|
userId: UserId;
|
||||||
/** Name of the stack */
|
/** Name of the stack */
|
||||||
name: string;
|
name: string;
|
||||||
/** Content of the Stack file */
|
/** Content of the Stack file */
|
||||||
|
@ -87,34 +90,7 @@ function createEdgeStack({ method, payload }: CreateEdgeStackPayload) {
|
||||||
Webhook: payload.webhook,
|
Webhook: payload.webhook,
|
||||||
});
|
});
|
||||||
case 'git':
|
case 'git':
|
||||||
return createStackFromGit({
|
return createStackAndGitCredential(payload.userId, payload);
|
||||||
deploymentType: payload.deploymentType,
|
|
||||||
edgeGroups: payload.edgeGroups,
|
|
||||||
name: payload.name,
|
|
||||||
envVars: payload.envVars,
|
|
||||||
prePullImage: payload.prePullImage,
|
|
||||||
registries: payload.registries,
|
|
||||||
retryDeploy: payload.retryDeploy,
|
|
||||||
staggerConfig: payload.staggerConfig,
|
|
||||||
useManifestNamespaces: payload.useManifestNamespaces,
|
|
||||||
repositoryUrl: payload.git.RepositoryURL,
|
|
||||||
repositoryReferenceName: payload.git.RepositoryReferenceName,
|
|
||||||
filePathInRepository: payload.git.ComposeFilePathInRepository,
|
|
||||||
repositoryAuthentication: payload.git.RepositoryAuthentication,
|
|
||||||
repositoryUsername: payload.git.RepositoryUsername,
|
|
||||||
repositoryPassword: payload.git.RepositoryPassword,
|
|
||||||
repositoryGitCredentialId: payload.git.RepositoryGitCredentialID,
|
|
||||||
filesystemPath: payload.relativePathSettings?.FilesystemPath,
|
|
||||||
supportRelativePath: payload.relativePathSettings?.SupportRelativePath,
|
|
||||||
perDeviceConfigsGroupMatchType:
|
|
||||||
payload.relativePathSettings?.PerDeviceConfigsGroupMatchType,
|
|
||||||
perDeviceConfigsMatchType:
|
|
||||||
payload.relativePathSettings?.PerDeviceConfigsMatchType,
|
|
||||||
perDeviceConfigsPath:
|
|
||||||
payload.relativePathSettings?.PerDeviceConfigsPath,
|
|
||||||
tlsSkipVerify: payload.git.TLSSkipVerify,
|
|
||||||
autoUpdate: payload.git.AutoUpdate,
|
|
||||||
});
|
|
||||||
case 'string':
|
case 'string':
|
||||||
return createStackFromFileContent({
|
return createStackFromFileContent({
|
||||||
deploymentType: payload.deploymentType,
|
deploymentType: payload.deploymentType,
|
||||||
|
@ -133,3 +109,41 @@ function createEdgeStack({ method, payload }: CreateEdgeStackPayload) {
|
||||||
throw new Error('Invalid method');
|
throw new Error('Invalid method');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function createStackAndGitCredential(
|
||||||
|
userId: UserId,
|
||||||
|
payload: BasePayload & {
|
||||||
|
git: GitFormModel;
|
||||||
|
relativePathSettings?: RelativePathModel;
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
const newGitModel = await saveGitCredentialsIfNeeded(userId, payload.git);
|
||||||
|
|
||||||
|
return createStackFromGit({
|
||||||
|
deploymentType: payload.deploymentType,
|
||||||
|
edgeGroups: payload.edgeGroups,
|
||||||
|
name: payload.name,
|
||||||
|
envVars: payload.envVars,
|
||||||
|
prePullImage: payload.prePullImage,
|
||||||
|
registries: payload.registries,
|
||||||
|
retryDeploy: payload.retryDeploy,
|
||||||
|
staggerConfig: payload.staggerConfig,
|
||||||
|
useManifestNamespaces: payload.useManifestNamespaces,
|
||||||
|
repositoryUrl: newGitModel.RepositoryURL,
|
||||||
|
repositoryReferenceName: newGitModel.RepositoryReferenceName,
|
||||||
|
filePathInRepository: newGitModel.ComposeFilePathInRepository,
|
||||||
|
repositoryAuthentication: newGitModel.RepositoryAuthentication,
|
||||||
|
repositoryUsername: newGitModel.RepositoryUsername,
|
||||||
|
repositoryPassword: newGitModel.RepositoryPassword,
|
||||||
|
repositoryGitCredentialId: newGitModel.RepositoryGitCredentialID,
|
||||||
|
filesystemPath: payload.relativePathSettings?.FilesystemPath,
|
||||||
|
supportRelativePath: payload.relativePathSettings?.SupportRelativePath,
|
||||||
|
perDeviceConfigsGroupMatchType:
|
||||||
|
payload.relativePathSettings?.PerDeviceConfigsGroupMatchType,
|
||||||
|
perDeviceConfigsMatchType:
|
||||||
|
payload.relativePathSettings?.PerDeviceConfigsMatchType,
|
||||||
|
perDeviceConfigsPath: payload.relativePathSettings?.PerDeviceConfigsPath,
|
||||||
|
tlsSkipVerify: newGitModel.TLSSkipVerify,
|
||||||
|
autoUpdate: newGitModel.AutoUpdate,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
|
@ -101,7 +101,4 @@ export type EdgeStack = RelativePathModel & {
|
||||||
FilesystemPath?: string;
|
FilesystemPath?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export enum EditorType {
|
export { DeploymentType as EditorType };
|
||||||
Compose,
|
|
||||||
Kubernetes,
|
|
||||||
}
|
|
||||||
|
|
|
@ -2,7 +2,7 @@ import { useQueryClient, useMutation } from '@tanstack/react-query';
|
||||||
|
|
||||||
import axios, { parseAxiosError } from '@/portainer/services/axios';
|
import axios, { parseAxiosError } from '@/portainer/services/axios';
|
||||||
import { notifyError, notifySuccess } from '@/portainer/services/notifications';
|
import { notifyError, notifySuccess } from '@/portainer/services/notifications';
|
||||||
import { GitAuthModel, GitFormModel } from '@/react/portainer/gitops/types';
|
import { GitAuthModel } from '@/react/portainer/gitops/types';
|
||||||
import { useCurrentUser } from '@/react/hooks/useUser';
|
import { useCurrentUser } from '@/react/hooks/useUser';
|
||||||
import { UserId } from '@/portainer/users/types';
|
import { UserId } from '@/portainer/users/types';
|
||||||
|
|
||||||
|
@ -82,9 +82,9 @@ export function useSaveCredentialsIfRequired() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function saveGitCredentialsIfNeeded(
|
export async function saveGitCredentialsIfNeeded<TGit extends GitAuthModel>(
|
||||||
userId: UserId,
|
userId: UserId,
|
||||||
gitModel: GitFormModel
|
gitModel: TGit
|
||||||
) {
|
) {
|
||||||
let credentialsId = gitModel.RepositoryGitCredentialID;
|
let credentialsId = gitModel.RepositoryGitCredentialID;
|
||||||
let username = gitModel.RepositoryUsername;
|
let username = gitModel.RepositoryUsername;
|
||||||
|
|
|
@ -2,7 +2,9 @@ import { VariableDefinition } from '../CustomTemplatesVariablesDefinitionField';
|
||||||
|
|
||||||
import { Values } from './CustomTemplatesVariablesField';
|
import { Values } from './CustomTemplatesVariablesField';
|
||||||
|
|
||||||
export function getDefaultValues(definitions: VariableDefinition[]): Values {
|
export function getDefaultValues(
|
||||||
|
definitions: VariableDefinition[] | undefined = []
|
||||||
|
): Values {
|
||||||
return definitions.map((v) => ({
|
return definitions.map((v) => ({
|
||||||
key: v.name,
|
key: v.name,
|
||||||
value: v.defaultValue,
|
value: v.defaultValue,
|
||||||
|
|
|
@ -1,9 +1,8 @@
|
||||||
import { useCallback } from 'react';
|
import { FormikErrors } from 'formik';
|
||||||
|
|
||||||
import { GitFormModel } from '@/react/portainer/gitops/types';
|
import { GitFormModel } from '@/react/portainer/gitops/types';
|
||||||
import { PathSelector } from '@/react/portainer/gitops/ComposePathField/PathSelector';
|
import { PathSelector } from '@/react/portainer/gitops/ComposePathField/PathSelector';
|
||||||
import { dummyGitForm } from '@/react/portainer/gitops/RelativePathFieldset/utils';
|
import { dummyGitForm } from '@/react/portainer/gitops/RelativePathFieldset/utils';
|
||||||
import { useValidation } from '@/react/portainer/gitops/RelativePathFieldset/useValidation';
|
|
||||||
import { useEnableFsPath } from '@/react/portainer/gitops/RelativePathFieldset/useEnableFsPath';
|
import { useEnableFsPath } from '@/react/portainer/gitops/RelativePathFieldset/useEnableFsPath';
|
||||||
|
|
||||||
import { SwitchField } from '@@/form-components/SwitchField';
|
import { SwitchField } from '@@/form-components/SwitchField';
|
||||||
|
@ -15,27 +14,22 @@ import { useDocsUrl } from '@@/PageHeader/ContextHelp/ContextHelp';
|
||||||
import { RelativePathModel, getPerDevConfigsFilterType } from './types';
|
import { RelativePathModel, getPerDevConfigsFilterType } from './types';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
value: RelativePathModel;
|
values: RelativePathModel;
|
||||||
gitModel?: GitFormModel;
|
gitModel?: GitFormModel;
|
||||||
onChange?: (value: Partial<RelativePathModel>) => void;
|
onChange: (value: RelativePathModel) => void;
|
||||||
isEditing?: boolean;
|
isEditing?: boolean;
|
||||||
hideEdgeConfigs?: boolean;
|
hideEdgeConfigs?: boolean;
|
||||||
|
errors?: FormikErrors<RelativePathModel>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function RelativePathFieldset({
|
export function RelativePathFieldset({
|
||||||
value,
|
values: value,
|
||||||
gitModel,
|
gitModel,
|
||||||
onChange,
|
onChange = () => {},
|
||||||
isEditing,
|
isEditing,
|
||||||
hideEdgeConfigs,
|
hideEdgeConfigs,
|
||||||
|
errors,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
const innerOnChange = useCallback(
|
|
||||||
(value: Partial<RelativePathModel>) => onChange && onChange(value),
|
|
||||||
[onChange]
|
|
||||||
);
|
|
||||||
|
|
||||||
const { errors } = useValidation(value);
|
|
||||||
|
|
||||||
const { enableFsPath0, enableFsPath1, toggleFsPath } = useEnableFsPath(value);
|
const { enableFsPath0, enableFsPath1, toggleFsPath } = useEnableFsPath(value);
|
||||||
|
|
||||||
const gitoptsEdgeConfigDocUrl = useDocsUrl(
|
const gitoptsEdgeConfigDocUrl = useDocsUrl(
|
||||||
|
@ -63,7 +57,7 @@ export function RelativePathFieldset({
|
||||||
checked={value.SupportRelativePath}
|
checked={value.SupportRelativePath}
|
||||||
onChange={(value) => {
|
onChange={(value) => {
|
||||||
toggleFsPath(0, value);
|
toggleFsPath(0, value);
|
||||||
innerOnChange({ SupportRelativePath: value });
|
handleChange({ SupportRelativePath: value });
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
@ -83,7 +77,7 @@ export function RelativePathFieldset({
|
||||||
<div className="col-sm-12">
|
<div className="col-sm-12">
|
||||||
<FormControl
|
<FormControl
|
||||||
label="Local filesystem path"
|
label="Local filesystem path"
|
||||||
errors={errors.FilesystemPath}
|
errors={errors?.FilesystemPath}
|
||||||
>
|
>
|
||||||
<Input
|
<Input
|
||||||
name="FilesystemPath"
|
name="FilesystemPath"
|
||||||
|
@ -92,7 +86,7 @@ export function RelativePathFieldset({
|
||||||
disabled={isEditing || !enableFsPath0}
|
disabled={isEditing || !enableFsPath0}
|
||||||
value={value.FilesystemPath}
|
value={value.FilesystemPath}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
innerOnChange({ FilesystemPath: e.target.value })
|
handleChange({ FilesystemPath: e.target.value })
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
|
@ -124,7 +118,7 @@ export function RelativePathFieldset({
|
||||||
checked={!!value.SupportPerDeviceConfigs}
|
checked={!!value.SupportPerDeviceConfigs}
|
||||||
onChange={(value) => {
|
onChange={(value) => {
|
||||||
toggleFsPath(1, value);
|
toggleFsPath(1, value);
|
||||||
innerOnChange({ SupportPerDeviceConfigs: value });
|
handleChange({ SupportPerDeviceConfigs: value });
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
@ -147,7 +141,7 @@ export function RelativePathFieldset({
|
||||||
<div className="col-sm-12">
|
<div className="col-sm-12">
|
||||||
<FormControl
|
<FormControl
|
||||||
label="Local filesystem path"
|
label="Local filesystem path"
|
||||||
errors={errors.FilesystemPath}
|
errors={errors?.FilesystemPath}
|
||||||
>
|
>
|
||||||
<Input
|
<Input
|
||||||
name="FilesystemPath"
|
name="FilesystemPath"
|
||||||
|
@ -156,7 +150,7 @@ export function RelativePathFieldset({
|
||||||
disabled={isEditing || !enableFsPath1}
|
disabled={isEditing || !enableFsPath1}
|
||||||
value={value.FilesystemPath}
|
value={value.FilesystemPath}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
innerOnChange({ FilesystemPath: e.target.value })
|
handleChange({ FilesystemPath: e.target.value })
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
|
@ -178,13 +172,13 @@ export function RelativePathFieldset({
|
||||||
<div className="col-sm-12">
|
<div className="col-sm-12">
|
||||||
<FormControl
|
<FormControl
|
||||||
label="Directory"
|
label="Directory"
|
||||||
errors={errors.PerDeviceConfigsPath}
|
errors={errors?.PerDeviceConfigsPath}
|
||||||
inputId="per_device_configs_path_input"
|
inputId="per_device_configs_path_input"
|
||||||
>
|
>
|
||||||
<PathSelector
|
<PathSelector
|
||||||
value={value.PerDeviceConfigsPath || ''}
|
value={value.PerDeviceConfigsPath || ''}
|
||||||
onChange={(value) =>
|
onChange={(value) =>
|
||||||
innerOnChange({ PerDeviceConfigsPath: value })
|
handleChange({ PerDeviceConfigsPath: value })
|
||||||
}
|
}
|
||||||
placeholder="config"
|
placeholder="config"
|
||||||
model={gitModel || dummyGitForm}
|
model={gitModel || dummyGitForm}
|
||||||
|
@ -216,7 +210,7 @@ export function RelativePathFieldset({
|
||||||
value={value.PerDeviceConfigsMatchType}
|
value={value.PerDeviceConfigsMatchType}
|
||||||
data-cy="per-device-configs-match-type-select"
|
data-cy="per-device-configs-match-type-select"
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
innerOnChange({
|
handleChange({
|
||||||
PerDeviceConfigsMatchType: getPerDevConfigsFilterType(
|
PerDeviceConfigsMatchType: getPerDevConfigsFilterType(
|
||||||
e.target.value
|
e.target.value
|
||||||
),
|
),
|
||||||
|
@ -249,7 +243,7 @@ export function RelativePathFieldset({
|
||||||
value={value.PerDeviceConfigsGroupMatchType}
|
value={value.PerDeviceConfigsGroupMatchType}
|
||||||
data-cy="per-device-configs-group-match-type-select"
|
data-cy="per-device-configs-group-match-type-select"
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
innerOnChange({
|
handleChange({
|
||||||
PerDeviceConfigsGroupMatchType:
|
PerDeviceConfigsGroupMatchType:
|
||||||
getPerDevConfigsFilterType(e.target.value),
|
getPerDevConfigsFilterType(e.target.value),
|
||||||
})
|
})
|
||||||
|
@ -301,4 +295,8 @@ export function RelativePathFieldset({
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
function handleChange(newValue: Partial<RelativePathModel>) {
|
||||||
|
onChange({ ...value, ...newValue });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,27 +0,0 @@
|
||||||
import { useEffect, useState } from 'react';
|
|
||||||
import { FormikErrors, yupToFormErrors } from 'formik';
|
|
||||||
|
|
||||||
import { RelativePathModel } from '@/react/portainer/gitops/types';
|
|
||||||
import { relativePathValidation } from '@/react/portainer/gitops/RelativePathFieldset/validation';
|
|
||||||
|
|
||||||
export function useValidation(value: RelativePathModel) {
|
|
||||||
const [errors, setErrors] = useState<FormikErrors<RelativePathModel>>({});
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
async function valide() {
|
|
||||||
try {
|
|
||||||
await relativePathValidation().validate(value, {
|
|
||||||
strict: true,
|
|
||||||
abortEarly: false,
|
|
||||||
});
|
|
||||||
setErrors({});
|
|
||||||
} catch (error) {
|
|
||||||
setErrors(yupToFormErrors(error));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
valide();
|
|
||||||
}, [value]);
|
|
||||||
|
|
||||||
return { errors };
|
|
||||||
}
|
|
|
@ -1,22 +1,20 @@
|
||||||
import angular from 'angular';
|
import angular from 'angular';
|
||||||
|
|
||||||
import { r2a } from '@/react-tools/react2angular';
|
|
||||||
import { withUIRouter } from '@/react-tools/withUIRouter';
|
import { withUIRouter } from '@/react-tools/withUIRouter';
|
||||||
import { withReactQuery } from '@/react-tools/withReactQuery';
|
import { withReactQuery } from '@/react-tools/withReactQuery';
|
||||||
import { RelativePathFieldset } from '@/react/portainer/gitops/RelativePathFieldset/RelativePathFieldset';
|
import { RelativePathFieldset } from '@/react/portainer/gitops/RelativePathFieldset/RelativePathFieldset';
|
||||||
|
import { withFormValidation } from '@/react-tools/withFormValidation';
|
||||||
|
|
||||||
export const ngModule = angular
|
import { relativePathValidation } from './RelativePathFieldset/validation';
|
||||||
.module('portainer.app.react.gitops', [])
|
|
||||||
|
|
||||||
.component(
|
export const ngModule = angular.module('portainer.app.react.gitops', []);
|
||||||
'relativePathFieldset',
|
|
||||||
r2a(withUIRouter(withReactQuery(RelativePathFieldset)), [
|
withFormValidation(
|
||||||
'value',
|
ngModule,
|
||||||
'gitModel',
|
withUIRouter(withReactQuery(RelativePathFieldset)),
|
||||||
'onChange',
|
'relativePathFieldset',
|
||||||
'isEditing',
|
['gitModel', 'hideEdgeConfigs', 'isEditing', 'onChange'],
|
||||||
'hideEdgeConfigs',
|
relativePathValidation
|
||||||
])
|
);
|
||||||
);
|
|
||||||
|
|
||||||
export const gitopsModule = ngModule.name;
|
export const gitopsModule = ngModule.name;
|
||||||
|
|
|
@ -59,8 +59,6 @@ export interface GitFormModel extends GitAuthModel {
|
||||||
RepositoryReferenceName?: string;
|
RepositoryReferenceName?: string;
|
||||||
AdditionalFiles?: string[];
|
AdditionalFiles?: string[];
|
||||||
|
|
||||||
SaveCredential?: boolean;
|
|
||||||
NewCredentialName?: string;
|
|
||||||
TLSSkipVerify?: boolean;
|
TLSSkipVerify?: boolean;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
@ -78,6 +78,7 @@ export function useGenericRegistriesQuery<T = Registry[]>(
|
||||||
export async function getRegistries() {
|
export async function getRegistries() {
|
||||||
try {
|
try {
|
||||||
const { data } = await axios.get<Registry[]>('/registries');
|
const { data } = await axios.get<Registry[]>('/registries');
|
||||||
|
|
||||||
return data;
|
return data;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
throw parseAxiosError(e as Error, 'Unable to retrieve registries');
|
throw parseAxiosError(e as Error, 'Unable to retrieve registries');
|
||||||
|
|
|
@ -12,19 +12,38 @@ import { buildUrl } from './build-url';
|
||||||
|
|
||||||
export function useAppTemplates<T = Array<TemplateViewModel>>({
|
export function useAppTemplates<T = Array<TemplateViewModel>>({
|
||||||
select,
|
select,
|
||||||
}: { select?: (templates: Array<TemplateViewModel>) => T } = {}) {
|
enabled = true,
|
||||||
const registriesQuery = useRegistries();
|
}: {
|
||||||
|
select?: (templates: Array<TemplateViewModel>) => T;
|
||||||
|
enabled?: boolean;
|
||||||
|
} = {}) {
|
||||||
|
const registriesQuery = useRegistries({ enabled });
|
||||||
|
|
||||||
return useQuery(
|
return useQuery(
|
||||||
['templates'],
|
['templates'],
|
||||||
() => getTemplatesWithRegistry(registriesQuery.data),
|
() => getTemplatesWithRegistry(registriesQuery.data),
|
||||||
{
|
{
|
||||||
enabled: !!registriesQuery.data,
|
enabled: !!registriesQuery.data && enabled,
|
||||||
select,
|
select,
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function useAppTemplate(
|
||||||
|
id: AppTemplate['id'] | undefined,
|
||||||
|
{ enabled = true }: { enabled?: boolean } = {}
|
||||||
|
) {
|
||||||
|
const templateListQuery = useAppTemplates({ enabled: !!id && enabled });
|
||||||
|
|
||||||
|
const template = templateListQuery.data?.find((t) => t.Id === id);
|
||||||
|
|
||||||
|
return {
|
||||||
|
data: template,
|
||||||
|
isLoading: templateListQuery.isLoading,
|
||||||
|
error: templateListQuery.error,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
async function getTemplatesWithRegistry(
|
async function getTemplatesWithRegistry(
|
||||||
registries: Array<Registry> | undefined
|
registries: Array<Registry> | undefined
|
||||||
) {
|
) {
|
||||||
|
|
|
@ -34,7 +34,7 @@ export function EdgeSettingsFieldset({
|
||||||
{isGit && (
|
{isGit && (
|
||||||
<FormSection title="Advanced settings">
|
<FormSection title="Advanced settings">
|
||||||
<RelativePathFieldset
|
<RelativePathFieldset
|
||||||
value={values.RelativePathSettings}
|
values={values.RelativePathSettings}
|
||||||
gitModel={gitConfig}
|
gitModel={gitConfig}
|
||||||
onChange={(newValues) =>
|
onChange={(newValues) =>
|
||||||
setValues((values) => ({
|
setValues((values) => ({
|
||||||
|
|
|
@ -1,5 +1,6 @@
|
||||||
import { SchemaOf, boolean, mixed, number, object } from 'yup';
|
import { SchemaOf, boolean, mixed, number, object } from 'yup';
|
||||||
|
|
||||||
|
import { staggerConfigValidation } from '@/react/edge/edge-stacks/components/StaggerFieldset';
|
||||||
import { relativePathValidation } from '@/react/portainer/gitops/RelativePathFieldset/validation';
|
import { relativePathValidation } from '@/react/portainer/gitops/RelativePathFieldset/validation';
|
||||||
import { EdgeTemplateSettings } from '@/react/portainer/templates/custom-templates/types';
|
import { EdgeTemplateSettings } from '@/react/portainer/templates/custom-templates/types';
|
||||||
import { isBE } from '@/react/portainer/feature-flags/feature-flags.service';
|
import { isBE } from '@/react/portainer/feature-flags/feature-flags.service';
|
||||||
|
@ -14,6 +15,6 @@ export function edgeFieldsetValidation(): SchemaOf<EdgeTemplateSettings> {
|
||||||
PrePullImage: boolean().default(false),
|
PrePullImage: boolean().default(false),
|
||||||
RetryDeploy: boolean().default(false),
|
RetryDeploy: boolean().default(false),
|
||||||
PrivateRegistryId: number().default(undefined),
|
PrivateRegistryId: number().default(undefined),
|
||||||
StaggerConfig: mixed(),
|
StaggerConfig: staggerConfigValidation(),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
@ -160,7 +160,7 @@ export function InnerForm({
|
||||||
...values,
|
...values,
|
||||||
EdgeSettings: applySetStateAction(
|
EdgeSettings: applySetStateAction(
|
||||||
edgeSetValues,
|
edgeSetValues,
|
||||||
values.EdgeSettings
|
values.EdgeSettings!
|
||||||
),
|
),
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
|
@ -167,7 +167,7 @@ export function InnerForm({
|
||||||
setValues={(edgeValues) =>
|
setValues={(edgeValues) =>
|
||||||
setFieldValue(
|
setFieldValue(
|
||||||
'EdgeSettings',
|
'EdgeSettings',
|
||||||
applySetStateAction(edgeValues, values.EdgeSettings)
|
applySetStateAction(edgeValues, values.EdgeSettings!)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
gitConfig={values.Git}
|
gitConfig={values.Git}
|
||||||
|
|
|
@ -17,9 +17,12 @@ export async function getCustomTemplate(id: CustomTemplate['Id']) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useCustomTemplate(id?: CustomTemplate['Id']) {
|
export function useCustomTemplate(
|
||||||
|
id?: CustomTemplate['Id'],
|
||||||
|
{ enabled = true }: { enabled?: boolean } = {}
|
||||||
|
) {
|
||||||
return useQuery(queryKeys.item(id!), () => getCustomTemplate(id!), {
|
return useQuery(queryKeys.item(id!), () => getCustomTemplate(id!), {
|
||||||
...withGlobalError('Unable to retrieve custom template'),
|
...withGlobalError('Unable to retrieve custom template'),
|
||||||
enabled: !!id,
|
enabled: !!id && enabled,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,5 +1,9 @@
|
||||||
import { UserId } from '@/portainer/users/types';
|
import { UserId } from '@/portainer/users/types';
|
||||||
import { StackType } from '@/react/common/stacks/types';
|
import { StackType } from '@/react/common/stacks/types';
|
||||||
|
import {
|
||||||
|
StaggerConfig,
|
||||||
|
getDefaultStaggerConfig,
|
||||||
|
} from '@/react/edge/edge-stacks/components/StaggerFieldset.types';
|
||||||
|
|
||||||
import { ResourceControlResponse } from '../../access-control/types';
|
import { ResourceControlResponse } from '../../access-control/types';
|
||||||
import { RelativePathModel, RepoConfigResponse } from '../../gitops/types';
|
import { RelativePathModel, RepoConfigResponse } from '../../gitops/types';
|
||||||
|
@ -105,6 +109,12 @@ export type EdgeTemplateSettings = {
|
||||||
PrivateRegistryId: RegistryId | undefined;
|
PrivateRegistryId: RegistryId | undefined;
|
||||||
|
|
||||||
RelativePathSettings: RelativePathModel;
|
RelativePathSettings: RelativePathModel;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* StaggerConfig is the configuration for staggered update
|
||||||
|
* required only on BE
|
||||||
|
*/
|
||||||
|
StaggerConfig: StaggerConfig;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type CustomTemplateFileContent = {
|
export type CustomTemplateFileContent = {
|
||||||
|
@ -113,7 +123,9 @@ export type CustomTemplateFileContent = {
|
||||||
|
|
||||||
export const CustomTemplateKubernetesType = StackType.Kubernetes;
|
export const CustomTemplateKubernetesType = StackType.Kubernetes;
|
||||||
|
|
||||||
export function getDefaultEdgeTemplateSettings() {
|
export function getDefaultEdgeTemplateSettings():
|
||||||
|
| EdgeTemplateSettings
|
||||||
|
| undefined {
|
||||||
if (!isBE) {
|
if (!isBE) {
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
@ -123,5 +135,6 @@ export function getDefaultEdgeTemplateSettings() {
|
||||||
RetryDeploy: false,
|
RetryDeploy: false,
|
||||||
PrivateRegistryId: undefined,
|
PrivateRegistryId: undefined,
|
||||||
RelativePathSettings: getDefaultRelativePathModel(),
|
RelativePathSettings: getDefaultRelativePathModel(),
|
||||||
|
StaggerConfig: getDefaultStaggerConfig(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in New Issue