refactor(edge/stacks): migrate create view to react [EE-2223] (#11575)

pull/11784/head
Chaim Lev-Ari 2024-05-06 08:08:03 +03:00 committed by GitHub
parent f22aed34b5
commit 8a81d95253
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
64 changed files with 1878 additions and 1005 deletions

View File

@ -69,7 +69,7 @@ angular
url: '/new?templateId&templateType',
views: {
'content@': {
component: 'createEdgeStackView',
component: 'edgeStacksCreateView',
},
},
data: {

View File

@ -13,7 +13,6 @@ import { withUIRouter } from '@/react-tools/withUIRouter';
import { EdgeGroupAssociationTable } from '@/react/edge/components/EdgeGroupAssociationTable';
import { AssociatedEdgeEnvironmentsSelector } from '@/react/edge/components/AssociatedEdgeEnvironmentsSelector';
import { EnvironmentsDatatable } from '@/react/edge/edge-stacks/ItemView/EnvironmentsDatatable';
import { TemplateFieldset } from '@/react/edge/edge-stacks/CreateView/TemplateFieldset/TemplateFieldset';
import { edgeJobsModule } from './edge-jobs';
@ -102,10 +101,6 @@ const ngModule = angular
'onChange',
'value',
])
)
.component(
'edgeStackCreateTemplateFieldset',
r2a(withReactQuery(TemplateFieldset), ['setValues', 'values', 'errors'])
);
export const componentsModule = ngModule.name;

View File

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

View File

@ -10,9 +10,14 @@ import { ListView as EdgeGroupsListView } from '@/react/edge/edge-groups/ListVie
import { templatesModule } from './templates';
import { jobsModule } from './jobs';
import { stacksModule } from './edge-stacks';
export const viewsModule = angular
.module('portainer.edge.react.views', [templatesModule, jobsModule])
.module('portainer.edge.react.views', [
templatesModule,
jobsModule,
stacksModule,
])
.component(
'waitingRoomView',
r2a(withUIRouter(withReactQuery(withCurrentUser(WaitingRoomView))), [])

View File

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

View File

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

View File

@ -1,6 +0,0 @@
import controller from './create-edge-stack-view.controller';
export const createEdgeStackView = {
templateUrl: './create-edge-stack-view.html',
controller,
};

View File

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

View File

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

View File

@ -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: '<',
},
};

View File

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

View File

@ -1,3 +0,0 @@
export const kubeDeployDescription = {
templateUrl: './kube-deploy-description.html',
};

View File

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

View File

@ -1,11 +0,0 @@
import controller from './kube-manifest-form.controller.js';
export const kubeManifestForm = {
templateUrl: './kube-manifest-form.html',
controller,
bindings: {
formValues: '=',
state: '=',
},
};

View File

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

View File

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

View File

@ -1,5 +1,3 @@
import angular from 'angular';
import createModule from './createEdgeStackView';
export default angular.module('portainer.edge.stacks', [createModule]).name;
export default angular.module('portainer.edge.stacks', []).name;

View File

@ -1,8 +1,8 @@
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)) {
return values ? applier(values) : undefined;
return applier(values);
}
return applier;

View File

@ -6,6 +6,7 @@ test('render should include description', async () => {
const onClick = vi.fn();
const { findByText } = render(
<FileUploadForm
value={undefined}
title="test button"
onChange={onClick}
description={<span>test description</span>}

View File

@ -6,8 +6,8 @@ import { FormSectionTitle } from '@@/form-components/FormSectionTitle';
import { FileUploadField } from '@@/form-components/FileUpload/FileUploadField';
export interface Props {
onChange(value: unknown): void;
value?: File;
onChange(value?: File): void;
value: File | undefined;
title?: string;
required?: boolean;
description: ReactNode;

View File

@ -12,6 +12,7 @@ interface Props {
defaultFolded?: boolean;
titleClassName?: string;
className?: string;
htmlFor?: string;
}
export function FormSection({
@ -22,13 +23,14 @@ export function FormSection({
defaultFolded = isFoldable,
titleClassName,
className,
htmlFor = '',
}: PropsWithChildren<Props>) {
const [isExpanded, setIsExpanded] = useState(!defaultFolded);
return (
<div className={className}>
<FormSectionTitle
htmlFor={isFoldable ? `foldingButton${title}` : ''}
htmlFor={isFoldable ? `foldingButton${title}` : htmlFor}
titleSize={titleSize}
className={titleClassName}
>

View File

@ -1,6 +1,6 @@
import clsx from 'clsx';
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 { AutomationTestingProps } from '@/types';
@ -33,7 +33,7 @@ export function SwitchField({
checked,
label,
index,
name = uuid(),
name,
labelClass,
fieldClass,
'data-cy': dataCy,
@ -44,13 +44,14 @@ export function SwitchField({
setTooltipHtmlMessage,
valueExplanation,
}: PropsWithChildren<Props>) {
const [toggleId] = useState(() => `toggle_${uuid()}`);
const toggleName = name ? `toggle_${name}` : '';
return (
<div className={clsx(styles.root, fieldClass)}>
<label
className={clsx('space-right control-label !p-0 text-left', labelClass)}
htmlFor={toggleName}
htmlFor={toggleId}
>
{label}
{tooltip && (
@ -60,7 +61,7 @@ export function SwitchField({
<Switch
className={clsx('space-right', switchClass)}
name={toggleName}
id={toggleName}
id={toggleId}
checked={checked}
disabled={disabled}
onChange={onChange}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,12 +1,17 @@
import { FormikErrors } from 'formik';
import { SchemaOf, string } from 'yup';
import { useMemo } from 'react';
import { STACK_NAME_VALIDATION_REGEX } from '@/react/constants';
import { EnvironmentType } from '@/react/portainer/environments/types';
import { FormControl } from '@@/form-components/FormControl';
import { Input } from '@@/form-components/Input';
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({
onChange,
@ -24,7 +29,7 @@ export function NameField({
onChange={(e) => onChange(e.target.value)}
value={value}
required
data-cy="edge-stack-create-name-input"
data-cy="edgeStackCreate-nameInput"
/>
</FormControl>
);
@ -49,3 +54,23 @@ export function nameValidation(
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]
);
}

View File

@ -1,13 +1,17 @@
import { render, screen } from '@testing-library/react';
import { HttpResponse, http } from 'msw';
import { EnvVarType } from '@/react/portainer/templates/app-templates/view-model';
import {
EnvVarType,
TemplateViewModel,
} from '@/react/portainer/templates/app-templates/view-model';
AppTemplate,
TemplateType,
} from '@/react/portainer/templates/app-templates/types';
import { server } from '@/setup-tests/server';
import { withTestQueryProvider } from '@/react/test-utils/withTestQuery';
import { AppTemplateFieldset } from './AppTemplateFieldset';
test('renders AppTemplateFieldset component', () => {
test('renders AppTemplateFieldset component', async () => {
const testedEnv = {
name: 'VAR2',
label: 'Variable 2',
@ -27,27 +31,44 @@ test('renders AppTemplateFieldset component', () => {
testedEnv,
];
const template = {
Note: 'This is a template note',
Env: env,
} as TemplateViewModel;
id: 1,
note: 'This is a template note',
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> = {
VAR1: 'value1',
VAR2: 'value2',
};
const onChange = vi.fn();
render(
<AppTemplateFieldset
template={template}
values={values}
onChange={onChange}
/>
server.use(
http.get('/api/templates', () =>
HttpResponse.json({ version: '3', templates: [template] })
),
http.get('/api/registries', () => HttpResponse.json([]))
);
const templateNoteElement = screen.getByText('This is a template note');
expect(templateNoteElement).toBeInTheDocument();
const onChange = vi.fn();
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, {
exact: false,

View File

@ -1,23 +1,31 @@
import { FormikErrors } from 'formik';
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 {
EnvVarsFieldset,
EnvVarsValue,
} from '@/react/portainer/templates/app-templates/DeployFormWidget/EnvVarsFieldset';
import { TemplateNote } from '@/react/portainer/templates/components/TemplateNote';
export function AppTemplateFieldset({
template,
templateId,
values,
onChange,
errors,
}: {
template: TemplateViewModel;
templateId: TemplateViewModel['Id'];
values: EnvVarsValue;
onChange: (value: EnvVarsValue) => void;
errors?: FormikErrors<EnvVarsValue>;
}) {
const templateQuery = useAppTemplate(templateId);
if (!templateQuery.data) {
return null;
}
const template = templateQuery.data;
return (
<>
<TemplateNote note={template.Note} />

View File

@ -1,6 +1,7 @@
import { CustomTemplatesVariablesField } from '@/react/portainer/custom-templates/components/CustomTemplatesVariablesField';
import { CustomTemplate } from '@/react/portainer/templates/custom-templates/types';
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';
@ -10,13 +11,21 @@ export function CustomTemplateFieldset({
errors,
onChange,
values,
template,
templateId,
}: {
values: Values['variables'];
onChange: (values: Values['variables']) => void;
errors: ArrayError<Values['variables']> | undefined;
template: CustomTemplate;
templateId: CustomTemplate['Id'];
}) {
const templateQuery = useCustomTemplate(templateId);
if (!templateQuery.data) {
return null;
}
const template = templateQuery.data;
return (
<>
<TemplateNote note={template.Note} />

View File

@ -1,49 +1,38 @@
import { SetStateAction, useEffect, useState } from 'react';
import { SetStateAction } from 'react';
import { FormikErrors } from 'formik';
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 { TemplateSelector } from './TemplateSelector';
import { SelectedTemplateValue, Values } from './types';
import { Values } from './types';
import { CustomTemplateFieldset } from './CustomTemplateFieldset';
import { AppTemplateFieldset } from './AppTemplateFieldset';
export function TemplateFieldset({
values: initialValues,
setValues: setInitialValues,
values,
setValues,
errors,
}: {
errors?: FormikErrors<Values>;
values: Values;
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 (
<>
<TemplateSelector
error={
typeof errors?.template === 'string' ? errors?.template : undefined
}
error={errors?.templateId}
value={values}
onChange={handleChangeTemplate}
/>
{values.template && (
{values.templateId && (
<>
{values.type === 'custom' && (
<CustomTemplateFieldset
template={values.template}
templateId={values.templateId}
values={values.variables}
onChange={(variables) =>
setValues((values) => ({ ...values, variables }))
@ -54,7 +43,7 @@ export function TemplateFieldset({
{values.type === 'app' && (
<AppTemplateFieldset
template={values.template}
templateId={values.templateId}
values={values.envVars}
onChange={(envVars) =>
setValues((values) => ({ ...values, envVars }))
@ -67,36 +56,36 @@ export function TemplateFieldset({
</>
);
function setValues(values: SetStateAction<Values>) {
setControlledValues(values);
setInitialValues(values);
}
function handleChangeTemplate(value?: SelectedTemplateValue) {
function handleChangeTemplate(
template: TemplateViewModel | CustomTemplate | undefined,
type: 'app' | 'custom' | undefined
): void {
setValues(() => {
if (!value || !value.type || !value.template) {
if (!template || !type) {
return {
type: undefined,
template: undefined,
templateId: undefined,
variables: [],
envVars: {},
};
}
if (value.type === 'app') {
if (type === 'app') {
return {
template: value.template,
type: value.type,
templateId: template.Id,
type,
variables: [],
envVars: getAppVariablesDefaultValues(value.template.Env || []),
envVars: getAppVariablesDefaultValues(
(template as TemplateViewModel).Env || []
),
};
}
return {
template: value.template,
type: value.type,
templateId: template.Id,
type,
variables: getVariablesFieldDefaultValues(
value.template.Variables || []
(template as CustomTemplate).Variables || []
),
envVars: {},
};
@ -106,10 +95,9 @@ export function TemplateFieldset({
export function getInitialTemplateValues(): Values {
return {
template: undefined,
templateId: undefined,
type: undefined,
variables: [],
file: '',
envVars: {},
};
}

View File

@ -7,8 +7,8 @@ import { CustomTemplate } from '@/react/portainer/templates/custom-templates/typ
import { server } from '@/setup-tests/server';
import selectEvent from '@/react/test-utils/react-select';
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';
test('renders TemplateSelector component', async () => {
@ -109,7 +109,10 @@ function renderComponent({
customTemplates = [],
error,
}: {
onChange?: (value: SelectedTemplateValue) => void;
onChange?: (
template: TemplateViewModel | CustomTemplate | undefined,
type: 'app' | 'custom' | undefined
) => void;
appTemplates?: Array<Partial<AppTemplate>>;
customTemplates?: Array<Partial<CustomTemplate>>;
error?: string;
@ -128,7 +131,7 @@ function renderComponent({
render(
<Wrapped
value={{ template: undefined, type: undefined }}
value={{ templateId: undefined, type: undefined }}
onChange={onChange}
error={error}
/>

View File

@ -4,6 +4,8 @@ import { GroupBase } from 'react-select';
import { useCustomTemplates } from '@/react/portainer/templates/custom-templates/queries/useCustomTemplates';
import { useAppTemplates } from '@/react/portainer/templates/app-templates/queries/useAppTemplates';
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 { Select as ReactSelect } from '@@/form-components/ReactSelect';
@ -16,10 +18,13 @@ export function TemplateSelector({
error,
}: {
value: SelectedTemplateValue;
onChange: (value: SelectedTemplateValue) => void;
onChange: (
template: TemplateViewModel | CustomTemplate | undefined,
type: 'app' | 'custom' | undefined
) => void;
error?: string;
}) {
const { getTemplate, options } = useOptions();
const { options, getTemplate } = useOptions();
return (
<FormControl label="Template" inputId="template_selector" errors={error}>
@ -28,26 +33,20 @@ export function TemplateSelector({
formatGroupLabel={GroupLabel}
placeholder="Select an Edge stack template"
value={{
label: value.template?.Title,
id: value.template?.Id,
templateId: value.templateId,
type: value.type,
}}
onChange={(value) => {
if (!value) {
onChange({
template: undefined,
type: undefined,
});
onChange(undefined, undefined);
return;
}
const { id, type } = value;
if (!id || type === undefined) {
const { templateId, type } = value;
if (!templateId || type === undefined) {
return;
}
const template = getTemplate({ id, type });
onChange({ template, type } as SelectedTemplateValue);
onChange(getTemplate({ type, id: templateId }), type);
}}
options={options}
data-cy="edge-stacks-create-template-selector"
@ -80,7 +79,8 @@ function useOptions() {
options:
appTemplatesQuery.data?.map((template) => ({
label: `${template.Title} - ${template.Description}`,
id: template.Id,
templateId: template.Id,
type: 'app' as 'app' | 'custom',
})) || [],
},
@ -90,14 +90,16 @@ function useOptions() {
customTemplatesQuery.data && customTemplatesQuery.data.length > 0
? customTemplatesQuery.data.map((template) => ({
label: `${template.Title} - ${template.Description}`,
id: template.Id,
templateId: template.Id,
type: 'custom' as 'app' | 'custom',
}))
: [
{
label: 'No edge custom templates available',
id: 0,
type: 'custom' as 'app' | 'custom',
templateId: undefined,
type: undefined,
},
],
},

View File

@ -1,14 +1,11 @@
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 =
| { template: CustomTemplate; type: 'custom' }
| { template: TemplateViewModel; type: 'app' }
| { template: undefined; type: undefined };
| { templateId: number; type: 'custom' }
| { templateId: number; type: 'app' }
| { templateId: undefined; type: undefined };
export type Values = {
file?: string;
variables: VariablesFieldValue;
envVars: Record<string, string>;
} & SelectedTemplateValue;

View File

@ -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 { VariableDefinition } from '@/react/portainer/custom-templates/components/CustomTemplatesVariablesDefinitionField';
import { envVarsFieldsetValidation } from '@/react/portainer/templates/app-templates/DeployFormWidget/EnvVarsFieldset';
import { TemplateEnv } from '@/react/portainer/templates/app-templates/types';
function validation({
import { Values } from './types';
export function templateFieldsetValidation({
customVariablesDefinitions,
envVarDefinitions,
}: {
customVariablesDefinitions: VariableDefinition[];
envVarDefinitions: Array<TemplateEnv>;
}) {
}): SchemaOf<Values> {
return object({
type: string().oneOf(['custom', 'app']).required(),
type: mixed<'app' | 'custom'>().oneOf(['custom', 'app']).optional(),
envVars: envVarsFieldsetValidation(envVarDefinitions)
.optional()
.when('type', {
is: 'app',
then: (schema: SchemaOf<unknown, never>) => schema.required(),
}),
file: mixed().optional(),
template: object().optional().default(null),
templateId: mixed()
.optional()
.when('type', {
is: true,
then: () => number().required(),
}),
variables: variablesFieldValidation(customVariablesDefinitions)
.optional()
.when('type', {
@ -30,5 +36,3 @@ function validation({
}),
});
}
export { validation as templateFieldsetValidation };

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -252,7 +252,13 @@ function InnerForm({
errors={errors.authentication}
/>
{isBE && <RelativePathFieldset value={values.relativePath} isEditing />}
{isBE && (
<RelativePathFieldset
values={values.relativePath}
isEditing
onChange={() => {}}
/>
)}
<EnvironmentVariablesPanel
onChange={(value) => setFieldValue('envVars', value)}

View File

@ -1,12 +1,13 @@
import _ from 'lodash';
import { useState } from 'react';
import { EdgeGroup } from '@/react/edge/edge-groups/types';
import { Select } from '@@/form-components/ReactSelect';
import { FormSection } from '@@/form-components/FormSection';
import { FormError } from '@@/form-components/FormError';
import { Link } from '@@/Link';
import { FormControl } from '@@/form-components/FormControl';
import { FormSection } from '@@/form-components/FormSection';
import { useEdgeGroups } from '../../edge-groups/queries/useEdgeGroups';
@ -29,20 +30,28 @@ export function EdgeGroupsSelector({
isGroupVisible = () => true,
required,
}: Props) {
const [inputId] = useState(() => _.uniqueId('edge-groups-selector-'));
const selector = (
<InnerSelector
value={value}
onChange={onChange}
isGroupVisible={isGroupVisible}
inputId={inputId}
/>
);
return horizontal ? (
<FormControl errors={error} label="Edge Groups" required={required}>
<FormControl
errors={error}
label="Edge Groups"
required={required}
inputId={inputId}
>
{selector}
</FormControl>
) : (
<FormSection title={`Edge Groups${required ? ' *' : ''}`}>
<FormSection title={`Edge Groups${required ? ' *' : ''}`} htmlFor={inputId}>
<div className="form-group">
<div className="col-sm-12">{selector} </div>
{error && (
@ -59,10 +68,12 @@ function InnerSelector({
value,
onChange,
isGroupVisible,
inputId,
}: {
isGroupVisible(group: EdgeGroup): boolean;
value: SingleValue[];
onChange: (value: SingleValue[]) => void;
inputId: string;
}) {
const edgeGroupsQuery = useEdgeGroups();
@ -86,6 +97,7 @@ function InnerSelector({
placeholder="Select one or multiple group(s)"
closeMenuOnSelect={false}
data-cy="edge-stacks-groups-selector"
inputId={inputId}
/>
) : (
<div className="small text-muted">

View File

@ -1,6 +1,4 @@
import _ from 'lodash';
import { EditorType } from '@/react/edge/edge-stacks/types';
import { DeploymentType } from '@/react/edge/edge-stacks/types';
import { BoxSelector } from '@@/BoxSelector';
import { BoxSelectorOption } from '@@/BoxSelector/types';
@ -10,8 +8,8 @@ import {
} from '@@/BoxSelector/common-options/deployment-methods';
interface Props {
value: number;
onChange(value: number): void;
value: DeploymentType;
onChange(value: DeploymentType): void;
hasDockerEndpoint: boolean;
hasKubeEndpoint: boolean;
allowKubeToSelectCompose?: boolean;
@ -24,10 +22,10 @@ export function EdgeStackDeploymentTypeSelector({
hasKubeEndpoint,
allowKubeToSelectCompose,
}: Props) {
const deploymentOptions: BoxSelectorOption<number>[] = _.compact([
const deploymentOptions: BoxSelectorOption<DeploymentType>[] = [
{
...compose,
value: EditorType.Compose,
value: DeploymentType.Compose,
disabled: () => !allowKubeToSelectCompose && hasKubeEndpoint,
tooltip: () =>
hasKubeEndpoint
@ -36,7 +34,7 @@ export function EdgeStackDeploymentTypeSelector({
},
{
...kubernetes,
value: EditorType.Kubernetes,
value: DeploymentType.Kubernetes,
disabled: () => hasDockerEndpoint,
tooltip: () =>
hasDockerEndpoint
@ -44,7 +42,7 @@ export function EdgeStackDeploymentTypeSelector({
: '',
iconType: 'logo',
},
]);
];
return (
<>

View File

@ -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 &apos;Update Configuration&apos; 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(),
});
}

View File

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

View File

@ -7,6 +7,8 @@ import {
GitFormModel,
RelativePathModel,
} 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';
@ -18,7 +20,8 @@ export function useCreateEdgeStack() {
return useMutation(createEdgeStack);
}
type BasePayload = {
export type BasePayload = {
userId: UserId;
/** Name of the stack */
name: string;
/** Content of the Stack file */
@ -87,34 +90,7 @@ function createEdgeStack({ method, payload }: CreateEdgeStackPayload) {
Webhook: payload.webhook,
});
case '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: 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,
});
return createStackAndGitCredential(payload.userId, payload);
case 'string':
return createStackFromFileContent({
deploymentType: payload.deploymentType,
@ -133,3 +109,41 @@ function createEdgeStack({ method, payload }: CreateEdgeStackPayload) {
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,
});
}

View File

@ -101,7 +101,4 @@ export type EdgeStack = RelativePathModel & {
FilesystemPath?: string;
};
export enum EditorType {
Compose,
Kubernetes,
}
export { DeploymentType as EditorType };

View File

@ -2,7 +2,7 @@ import { useQueryClient, useMutation } from '@tanstack/react-query';
import axios, { parseAxiosError } from '@/portainer/services/axios';
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 { 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,
gitModel: GitFormModel
gitModel: TGit
) {
let credentialsId = gitModel.RepositoryGitCredentialID;
let username = gitModel.RepositoryUsername;

View File

@ -2,7 +2,9 @@ import { VariableDefinition } from '../CustomTemplatesVariablesDefinitionField';
import { Values } from './CustomTemplatesVariablesField';
export function getDefaultValues(definitions: VariableDefinition[]): Values {
export function getDefaultValues(
definitions: VariableDefinition[] | undefined = []
): Values {
return definitions.map((v) => ({
key: v.name,
value: v.defaultValue,

View File

@ -1,9 +1,8 @@
import { useCallback } from 'react';
import { FormikErrors } from 'formik';
import { GitFormModel } from '@/react/portainer/gitops/types';
import { PathSelector } from '@/react/portainer/gitops/ComposePathField/PathSelector';
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 { SwitchField } from '@@/form-components/SwitchField';
@ -15,27 +14,22 @@ import { useDocsUrl } from '@@/PageHeader/ContextHelp/ContextHelp';
import { RelativePathModel, getPerDevConfigsFilterType } from './types';
interface Props {
value: RelativePathModel;
values: RelativePathModel;
gitModel?: GitFormModel;
onChange?: (value: Partial<RelativePathModel>) => void;
onChange: (value: RelativePathModel) => void;
isEditing?: boolean;
hideEdgeConfigs?: boolean;
errors?: FormikErrors<RelativePathModel>;
}
export function RelativePathFieldset({
value,
values: value,
gitModel,
onChange,
onChange = () => {},
isEditing,
hideEdgeConfigs,
errors,
}: Props) {
const innerOnChange = useCallback(
(value: Partial<RelativePathModel>) => onChange && onChange(value),
[onChange]
);
const { errors } = useValidation(value);
const { enableFsPath0, enableFsPath1, toggleFsPath } = useEnableFsPath(value);
const gitoptsEdgeConfigDocUrl = useDocsUrl(
@ -63,7 +57,7 @@ export function RelativePathFieldset({
checked={value.SupportRelativePath}
onChange={(value) => {
toggleFsPath(0, value);
innerOnChange({ SupportRelativePath: value });
handleChange({ SupportRelativePath: value });
}}
/>
</div>
@ -83,7 +77,7 @@ export function RelativePathFieldset({
<div className="col-sm-12">
<FormControl
label="Local filesystem path"
errors={errors.FilesystemPath}
errors={errors?.FilesystemPath}
>
<Input
name="FilesystemPath"
@ -92,7 +86,7 @@ export function RelativePathFieldset({
disabled={isEditing || !enableFsPath0}
value={value.FilesystemPath}
onChange={(e) =>
innerOnChange({ FilesystemPath: e.target.value })
handleChange({ FilesystemPath: e.target.value })
}
/>
</FormControl>
@ -124,7 +118,7 @@ export function RelativePathFieldset({
checked={!!value.SupportPerDeviceConfigs}
onChange={(value) => {
toggleFsPath(1, value);
innerOnChange({ SupportPerDeviceConfigs: value });
handleChange({ SupportPerDeviceConfigs: value });
}}
/>
</div>
@ -147,7 +141,7 @@ export function RelativePathFieldset({
<div className="col-sm-12">
<FormControl
label="Local filesystem path"
errors={errors.FilesystemPath}
errors={errors?.FilesystemPath}
>
<Input
name="FilesystemPath"
@ -156,7 +150,7 @@ export function RelativePathFieldset({
disabled={isEditing || !enableFsPath1}
value={value.FilesystemPath}
onChange={(e) =>
innerOnChange({ FilesystemPath: e.target.value })
handleChange({ FilesystemPath: e.target.value })
}
/>
</FormControl>
@ -178,13 +172,13 @@ export function RelativePathFieldset({
<div className="col-sm-12">
<FormControl
label="Directory"
errors={errors.PerDeviceConfigsPath}
errors={errors?.PerDeviceConfigsPath}
inputId="per_device_configs_path_input"
>
<PathSelector
value={value.PerDeviceConfigsPath || ''}
onChange={(value) =>
innerOnChange({ PerDeviceConfigsPath: value })
handleChange({ PerDeviceConfigsPath: value })
}
placeholder="config"
model={gitModel || dummyGitForm}
@ -216,7 +210,7 @@ export function RelativePathFieldset({
value={value.PerDeviceConfigsMatchType}
data-cy="per-device-configs-match-type-select"
onChange={(e) =>
innerOnChange({
handleChange({
PerDeviceConfigsMatchType: getPerDevConfigsFilterType(
e.target.value
),
@ -249,7 +243,7 @@ export function RelativePathFieldset({
value={value.PerDeviceConfigsGroupMatchType}
data-cy="per-device-configs-group-match-type-select"
onChange={(e) =>
innerOnChange({
handleChange({
PerDeviceConfigsGroupMatchType:
getPerDevConfigsFilterType(e.target.value),
})
@ -301,4 +295,8 @@ export function RelativePathFieldset({
)}
</>
);
function handleChange(newValue: Partial<RelativePathModel>) {
onChange({ ...value, ...newValue });
}
}

View File

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

View File

@ -1,22 +1,20 @@
import angular from 'angular';
import { r2a } from '@/react-tools/react2angular';
import { withUIRouter } from '@/react-tools/withUIRouter';
import { withReactQuery } from '@/react-tools/withReactQuery';
import { RelativePathFieldset } from '@/react/portainer/gitops/RelativePathFieldset/RelativePathFieldset';
import { withFormValidation } from '@/react-tools/withFormValidation';
export const ngModule = angular
.module('portainer.app.react.gitops', [])
import { relativePathValidation } from './RelativePathFieldset/validation';
.component(
'relativePathFieldset',
r2a(withUIRouter(withReactQuery(RelativePathFieldset)), [
'value',
'gitModel',
'onChange',
'isEditing',
'hideEdgeConfigs',
])
);
export const ngModule = angular.module('portainer.app.react.gitops', []);
withFormValidation(
ngModule,
withUIRouter(withReactQuery(RelativePathFieldset)),
'relativePathFieldset',
['gitModel', 'hideEdgeConfigs', 'isEditing', 'onChange'],
relativePathValidation
);
export const gitopsModule = ngModule.name;

View File

@ -59,8 +59,6 @@ export interface GitFormModel extends GitAuthModel {
RepositoryReferenceName?: string;
AdditionalFiles?: string[];
SaveCredential?: boolean;
NewCredentialName?: string;
TLSSkipVerify?: boolean;
/**

View File

@ -78,6 +78,7 @@ export function useGenericRegistriesQuery<T = Registry[]>(
export async function getRegistries() {
try {
const { data } = await axios.get<Registry[]>('/registries');
return data;
} catch (e) {
throw parseAxiosError(e as Error, 'Unable to retrieve registries');

View File

@ -12,19 +12,38 @@ import { buildUrl } from './build-url';
export function useAppTemplates<T = Array<TemplateViewModel>>({
select,
}: { select?: (templates: Array<TemplateViewModel>) => T } = {}) {
const registriesQuery = useRegistries();
enabled = true,
}: {
select?: (templates: Array<TemplateViewModel>) => T;
enabled?: boolean;
} = {}) {
const registriesQuery = useRegistries({ enabled });
return useQuery(
['templates'],
() => getTemplatesWithRegistry(registriesQuery.data),
{
enabled: !!registriesQuery.data,
enabled: !!registriesQuery.data && enabled,
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(
registries: Array<Registry> | undefined
) {

View File

@ -34,7 +34,7 @@ export function EdgeSettingsFieldset({
{isGit && (
<FormSection title="Advanced settings">
<RelativePathFieldset
value={values.RelativePathSettings}
values={values.RelativePathSettings}
gitModel={gitConfig}
onChange={(newValues) =>
setValues((values) => ({

View File

@ -1,5 +1,6 @@
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 { EdgeTemplateSettings } from '@/react/portainer/templates/custom-templates/types';
import { isBE } from '@/react/portainer/feature-flags/feature-flags.service';
@ -14,6 +15,6 @@ export function edgeFieldsetValidation(): SchemaOf<EdgeTemplateSettings> {
PrePullImage: boolean().default(false),
RetryDeploy: boolean().default(false),
PrivateRegistryId: number().default(undefined),
StaggerConfig: mixed(),
StaggerConfig: staggerConfigValidation(),
});
}

View File

@ -160,7 +160,7 @@ export function InnerForm({
...values,
EdgeSettings: applySetStateAction(
edgeSetValues,
values.EdgeSettings
values.EdgeSettings!
),
}))
}

View File

@ -167,7 +167,7 @@ export function InnerForm({
setValues={(edgeValues) =>
setFieldValue(
'EdgeSettings',
applySetStateAction(edgeValues, values.EdgeSettings)
applySetStateAction(edgeValues, values.EdgeSettings!)
)
}
gitConfig={values.Git}

View File

@ -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!), {
...withGlobalError('Unable to retrieve custom template'),
enabled: !!id,
enabled: !!id && enabled,
});
}

View File

@ -1,5 +1,9 @@
import { UserId } from '@/portainer/users/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 { RelativePathModel, RepoConfigResponse } from '../../gitops/types';
@ -105,6 +109,12 @@ export type EdgeTemplateSettings = {
PrivateRegistryId: RegistryId | undefined;
RelativePathSettings: RelativePathModel;
/**
* StaggerConfig is the configuration for staggered update
* required only on BE
*/
StaggerConfig: StaggerConfig;
};
export type CustomTemplateFileContent = {
@ -113,7 +123,9 @@ export type CustomTemplateFileContent = {
export const CustomTemplateKubernetesType = StackType.Kubernetes;
export function getDefaultEdgeTemplateSettings() {
export function getDefaultEdgeTemplateSettings():
| EdgeTemplateSettings
| undefined {
if (!isBE) {
return undefined;
}
@ -123,5 +135,6 @@ export function getDefaultEdgeTemplateSettings() {
RetryDeploy: false,
PrivateRegistryId: undefined,
RelativePathSettings: getDefaultRelativePathModel(),
StaggerConfig: getDefaultStaggerConfig(),
};
}