Compare commits

...

2 Commits

Author SHA1 Message Date
Felix Han
f778495960 feat(stack): updated Edit Stack view EE-781 2021-06-22 01:44:36 +12:00
fhanportainer
85bdcca8c6 feat(stack): updated Add Stack view EE-780. (#5140)
* feat(stack): updated Add Stack view EE-780.

* feat(stack): using lib to validate time interval.

* feat(stack): updated interval format directive name

* feat(stack): removed incorrect html property.

* feat(stack): updated based on comments
2021-06-20 15:44:17 +03:00
12 changed files with 581 additions and 109 deletions

View File

@@ -0,0 +1,24 @@
import parse from 'parse-duration';
angular.module('portainer.app').directive('intervalFormat', function () {
return {
restrict: 'A',
require: 'ngModel',
link: function ($scope, $element, $attrs, ngModel) {
ngModel.$validators.invalidIntervalFormat = function (modelValue) {
try {
return modelValue && modelValue.toUpperCase().match(/^P?(?!$)(\d+Y)?(\d+M)?(\d+W)?(\d+D)?(T?(?=\d+[HMS])(\d+H)?(\d+M)?(\d+S)?)?$/gm) !== null;
} catch (error) {
return false;
}
};
ngModel.$validators.minimumInterval = function (modelValue) {
try {
return modelValue && parse(modelValue, 'minute') >= 1;
} catch (error) {
return false;
}
};
},
};
});

View File

@@ -2,16 +2,20 @@
<div class="col-sm-12 form-section-title">
Stack duplication / migration
</div>
<div class="form-group">
<span class="small" style="margin-top: 10px;">
<p class="text-muted">
This feature allows you to duplicate or migrate this stack.
</p>
</span>
<div>
<div class="form-group">
<input class="form-control" placeholder="Stack name (optional for migration)" aria-placeholder="Stack name" ng-model="$ctrl.formValues.newName" />
</div>
<div class="col-sm-12">
<span class="small" style="margin-top: 10px;">
<p class="text-muted">
This feature allows you to duplicate or migrate this stack.
</p>
</span>
<input class="form-control" placeholder="Stack name (optional for migration)" aria-placeholder="Stack name" ng-model="$ctrl.formValues.newName" />
</div>
</div>
<div class="form-group">
<div class="col-sm-12">
<endpoint-selector ng-if="$ctrl.endpoints && $ctrl.groups" model="$ctrl.formValues.endpoint" endpoints="$ctrl.endpoints" groups="$ctrl.groups"></endpoint-selector>
<button
class="btn btn-sm btn-primary"
@@ -33,9 +37,9 @@
<span ng-hide="$ctrl.state.duplicationInProgress"> <i class="fa fa-clone space-right" aria-hidden="true"></i> Duplicate </span>
<span ng-show="$ctrl.state.duplicationInProgress">Duplication in progress...</span>
</button>
<div ng-if="$ctrl.yamlError"
><span class="text-danger small">{{ $ctrl.yamlError }}</span></div
>
<div ng-if="$ctrl.yamlError">
<span class="text-danger small">{{ $ctrl.yamlError }}</span>
</div>
</div>
</div>
</div>

View File

@@ -1,16 +1,21 @@
angular.module('portainer.app').factory('WebhookHelper', [
'$location',
'API_ENDPOINT_WEBHOOKS',
function WebhookHelperFactory($location, API_ENDPOINT_WEBHOOKS) {
'API_ENDPOINT_STACKS',
function WebhookHelperFactory($location, API_ENDPOINT_WEBHOOKS, API_ENDPOINT_STACKS) {
'use strict';
var helper = {};
const protocol = $location.protocol().toLowerCase();
const port = $location.port();
const displayPort = (protocol === 'http' && port === 80) || (protocol === 'https' && port === 443) ? '' : ':' + port;
helper.returnWebhookUrl = function (token) {
var displayPort =
($location.protocol().toLowerCase() === 'http' && $location.port() === 80) || ($location.protocol().toLowerCase() === 'https' && $location.port() === 443)
? ''
: ':' + $location.port();
return $location.protocol() + '://' + $location.host() + displayPort + '/' + API_ENDPOINT_WEBHOOKS + '/' + token;
return `${protocol}://${$location.host()}${displayPort}/${API_ENDPOINT_WEBHOOKS}/${token}`;
};
helper.returnStackWebhookUrl = function (token) {
return `${protocol}://${$location.host()}${displayPort}/${API_ENDPOINT_STACKS}/webhooks/${token}`;
};
return helper;

View File

@@ -12,6 +12,8 @@ angular.module('portainer.app').factory('Stack', [
query: { method: 'GET', isArray: true },
create: { method: 'POST', ignoreLoadingBar: true },
update: { method: 'PUT', params: { id: '@id' }, ignoreLoadingBar: true },
updateGitStack: { method: 'PUT', params: { id: '@id', method: 'repository' }, ignoreLoadingBar: true },
redeployGitStack: { method: 'PUT', params: { id: '@id', action: 'redeploy' }, ignoreLoadingBar: true },
remove: { method: 'DELETE', params: { id: '@id', external: '@external', endpointId: '@endpointId' } },
getStackFile: { method: 'GET', params: { id: '@id', action: 'file' } },
migrate: { method: 'POST', params: { id: '@id', action: 'migrate', endpointId: '@endpointId' }, ignoreLoadingBar: true },

View File

@@ -221,6 +221,14 @@ angular.module('portainer.app').factory('StackService', [
return Stack.update({ endpointId: stack.EndpointId }, { id: stack.Id, StackFileContent: stackFile, Env: env, Prune: prune }).$promise;
};
service.redeployGitStack = function (stackId, endpointId, payload) {
return Stack.redeployGitStack({ endpointId: endpointId, id: stackId }, payload).$promise;
};
service.updateGitStack = function (stackId, endpointId, payload) {
return Stack.updateGitStack({ endpointId: endpointId, id: stackId }, payload).$promise;
};
service.createComposeStackFromFileUpload = function (name, stackFile, env, endpointId) {
return FileUploadService.createComposeStack(name, stackFile, env, endpointId);
};
@@ -279,12 +287,16 @@ angular.module('portainer.app').factory('StackService', [
Name: name,
RepositoryURL: repositoryOptions.RepositoryURL,
RepositoryReferenceName: repositoryOptions.RepositoryReferenceName,
ComposeFilePathInRepository: repositoryOptions.ComposeFilePathInRepository,
ComposeFile: repositoryOptions.ComposeFilePathInRepository,
AdditionalFiles: repositoryOptions.AdditionalFiles,
RepositoryAuthentication: repositoryOptions.RepositoryAuthentication,
RepositoryUsername: repositoryOptions.RepositoryUsername,
RepositoryPassword: repositoryOptions.RepositoryPassword,
Env: env,
};
if (repositoryOptions.AutoUpdate) {
payload.AutoUpdate = repositoryOptions.AutoUpdate;
}
return Stack.create({ method: 'repository', type: 2, endpointId: endpointId }, payload).$promise;
};
@@ -299,12 +311,18 @@ angular.module('portainer.app').factory('StackService', [
SwarmID: swarm.Id,
RepositoryURL: repositoryOptions.RepositoryURL,
RepositoryReferenceName: repositoryOptions.RepositoryReferenceName,
ComposeFilePathInRepository: repositoryOptions.ComposeFilePathInRepository,
ComposeFile: repositoryOptions.ComposeFilePathInRepository,
AdditionalFiles: repositoryOptions.AdditionalFiles,
RepositoryAuthentication: repositoryOptions.RepositoryAuthentication,
RepositoryUsername: repositoryOptions.RepositoryUsername,
RepositoryPassword: repositoryOptions.RepositoryPassword,
Env: env,
};
if (repositoryOptions.AutoUpdate) {
payload.AutoUpdate = repositoryOptions.AutoUpdate;
}
return Stack.create({ method: 'repository', type: 1, endpointId: endpointId }, payload).$promise;
})
.then(function success(data) {

View File

@@ -290,6 +290,26 @@ angular.module('portainer.app').factory('ModalService', [
);
};
service.confirmRedeployStackViaGit = function (callback) {
const message = $sanitize(
'Any changes to this stack made locally in Portainer will be overriden by the definition in git and may cause service interruption. Do you wish to continue?'
);
service.confirm({
title: 'Are you sure?',
message: message,
buttons: {
confirm: {
label: 'Update',
className: 'btn-warning',
},
cancel: {
label: 'cancel',
},
},
callback: callback,
});
};
return service;
},
]);

View File

@@ -1,6 +1,6 @@
import angular from 'angular';
import _ from 'lodash-es';
import uuidv4 from 'uuid/v4';
import { AccessControlFormData } from '../../../components/accessControlForm/porAccessControlFormModel';
angular
@@ -21,7 +21,10 @@ angular
StackHelper,
ContainerHelper,
CustomTemplateService,
ContainerService
ContainerService,
endpoint,
WebhookHelper,
clipboard
) {
$scope.formValues = {
Name: '',
@@ -29,10 +32,14 @@ angular
StackFile: null,
RepositoryURL: '',
RepositoryReferenceName: '',
RepositoryAutomaticUpdates: true,
RepositoryAuthentication: false,
RepositoryUsername: '',
RepositoryPassword: '',
RepositoryMechanism: 'Interval',
RepositoryWebhookURL: WebhookHelper.returnStackWebhookUrl(uuidv4()),
Env: [],
AdditionalFiles: [],
ComposeFilePathInRepository: 'docker-compose.yml',
AccessControlData: new AccessControlFormData(),
};
@@ -61,6 +68,14 @@ angular
$scope.formValues.Env.splice(index, 1);
};
$scope.addAdditionalFiles = function () {
$scope.formValues.AdditionalFiles.push('');
};
$scope.removeAdditionalFiles = function (index) {
$scope.formValues.AdditionalFiles.splice(index, 1);
};
function validateForm(accessControlData, isAdmin) {
$scope.state.formValidationError = '';
var error = '';
@@ -89,17 +104,32 @@ angular
if (method === 'repository') {
var repositoryOptions = {
AdditionalFiles: $scope.formValues.AdditionalFiles,
RepositoryURL: $scope.formValues.RepositoryURL,
RepositoryReferenceName: $scope.formValues.RepositoryReferenceName,
ComposeFilePathInRepository: $scope.formValues.ComposeFilePathInRepository,
RepositoryAuthentication: $scope.formValues.RepositoryAuthentication,
RepositoryUsername: $scope.formValues.RepositoryUsername,
RepositoryPassword: $scope.formValues.RepositoryPassword,
RepositoryPassword: $scope.formValues.RepositoryPersonalAccessToken,
};
getAutoUpdatesProperty(repositoryOptions);
return StackService.createSwarmStackFromGitRepository(name, repositoryOptions, env, endpointId);
}
}
function getAutoUpdatesProperty(repositoryOptions) {
if ($scope.formValues.RepositoryAutomaticUpdates) {
repositoryOptions.AutoUpdate = {};
if ($scope.formValues.RepositoryMechanism === 'Interval') {
repositoryOptions.AutoUpdate.Interval = $scope.formValues.RepositoryFetchInterval;
} else if ($scope.formValues.RepositoryMechanism === 'Webhook') {
repositoryOptions.AutoUpdate.Webhook = $scope.formValues.RepositoryWebhookURL.split('/').reverse()[0];
}
}
}
function createComposeStack(name, method) {
var env = FormHelper.removeInvalidEnvVars($scope.formValues.Env);
const endpointId = +$state.params.endpointId;
@@ -112,17 +142,27 @@ angular
return StackService.createComposeStackFromFileUpload(name, stackFile, env, endpointId);
} else if (method === 'repository') {
var repositoryOptions = {
AdditionalFiles: $scope.formValues.AdditionalFiles,
RepositoryURL: $scope.formValues.RepositoryURL,
RepositoryReferenceName: $scope.formValues.RepositoryReferenceName,
ComposeFilePathInRepository: $scope.formValues.ComposeFilePathInRepository,
RepositoryAuthentication: $scope.formValues.RepositoryAuthentication,
RepositoryUsername: $scope.formValues.RepositoryUsername,
RepositoryPassword: $scope.formValues.RepositoryPassword,
RepositoryPassword: $scope.formValues.RepositoryPersonalAccessToken,
};
getAutoUpdatesProperty(repositoryOptions);
return StackService.createComposeStackFromGitRepository(name, repositoryOptions, env, endpointId);
}
}
$scope.copyWebhook = function () {
clipboard.copyText($scope.formValues.RepositoryWebhookURL);
$('#copyNotification').show();
$('#copyNotification').fadeOut(2000);
};
$scope.deployStack = function () {
var name = $scope.formValues.Name;
var method = $scope.state.Method;

View File

@@ -7,7 +7,7 @@
<div class="col-sm-12">
<rd-widget>
<rd-widget-body>
<form class="form-horizontal">
<form class="form-horizontal" name="createStackForm">
<!-- name-input -->
<div class="form-group">
<label for="stack_name" class="col-sm-1 control-label text-left">Name</label>
@@ -175,6 +175,32 @@
<input type="text" class="form-control" ng-model="formValues.ComposeFilePathInRepository" id="stack_repository_path" placeholder="docker-compose.yml" />
</div>
</div>
<!-- additional paths-variables -->
<div>
<div class="form-group">
<div class="col-sm-12" style="margin-top: 5px;">
<label class="control-label text-left">Additional paths</label>
<span class="label label-default interactive" style="margin-left: 10px;" ng-click="addAdditionalFiles()">
<i class="fa fa-plus-circle" aria-hidden="true"></i> add file
</span>
</div>
<!-- additional-paths-variable-input-list -->
<div class="col-sm-12 form-inline" style="margin-top: 10px;">
<div ng-repeat="variable in formValues.AdditionalFiles track by $index" style="margin-top: 2px;">
<div class="input-group col-sm-5 input-group-sm">
<span class="input-group-addon">path</span>
<input id="ipv6_network_auxaddr_{{ $index }}" type="text" class="form-control" ng-model="formValues.AdditionalFiles[$index]" />
</div>
<button class="btn btn-sm btn-danger" type="button" ng-click="removeAdditionalFiles($index)">
<i class="fa fa-trash" aria-hidden="true"></i>
</button>
</div>
</div>
<!-- !additional-paths-variable-input-list -->
</div>
</div>
<!-- !additional-variables -->
<div class="form-group">
<div class="col-sm-12">
<label class="control-label text-left">
@@ -183,22 +209,90 @@
<label class="switch" style="margin-left: 20px;"> <input type="checkbox" ng-model="formValues.RepositoryAuthentication" /><i></i> </label>
</div>
</div>
<div class="form-group" ng-if="formValues.RepositoryAuthentication">
<span class="col-sm-12 text-muted small">
If your git account has 2FA enabled, you may receive an <code>authentication required</code> error when deploying your stack. In this case, you will need to provide
a personal-access token instead of your password.
</span>
<label for="repository_username" class="col-sm-2 control-label text-left">Username</label>
<div class="col-sm-3">
<input type="text" class="form-control" ng-model="formValues.RepositoryUsername" name="repository_username" placeholder="git username" />
</div>
</div>
<div class="form-group" ng-if="formValues.RepositoryAuthentication">
<label for="repository_username" class="col-sm-1 control-label text-left">Username</label>
<div class="col-sm-11 col-md-5">
<input type="text" class="form-control" ng-model="formValues.RepositoryUsername" name="repository_username" placeholder="myGitUser" />
</div>
<label for="repository_password" class="col-sm-1 control-label text-left">
Password
<label for="repository_personal_access_token" class="col-sm-2 control-label text-left">
Personal Access Token
<portainer-tooltip position="bottom" message="Provide a personal access token or password"></portainer-tooltip>
</label>
<div class="col-sm-11 col-md-5">
<input type="password" class="form-control" ng-model="formValues.RepositoryPassword" name="repository_password" placeholder="myPassword" />
<div class="col-sm-3">
<input
type="password"
class="form-control"
ng-model="formValues.RepositoryPersonalAccessToken"
name="repository_personal_access_token"
placeholder="personal access token"
/>
</div>
</div>
<div class="form-group">
<div class="col-sm-12">
<label class="control-label text-left">
Automatic updates
</label>
<label class="switch" style="margin-left: 20px;"> <input type="checkbox" ng-model="formValues.RepositoryAutomaticUpdates" /><i></i> </label>
</div>
</div>
<div class="small text-warning" style="margin-top: 5px;" ng-if="formValues.RepositoryAutomaticUpdates">
<i class="fa fa-exclamation-circle" aria-hidden="true"></i>
<span class="text-muted">Enabling automatic updates will store the credentials and it is advisable to use a git service account.</span>
</div>
<div class="small text-warning" style="margin: 5px 0 10px 0;" ng-if="formValues.RepositoryAutomaticUpdates">
<i class="fa fa-exclamation-circle" aria-hidden="true"></i>
<span class="text-muted">Any changes to this stack made locally in Portainer will be overriden by the definition in git and may cause service interruption.</span>
</div>
<div class="form-group" ng-if="formValues.RepositoryAutomaticUpdates">
<label for="repository_mechanism" class="col-sm-2 control-label text-left">
Mechanism
</label>
<div class="col-sm-3">
<div class="input-group col-sm-10 input-group-sm">
<div class="btn-group btn-group-sm">
<label class="btn btn-primary" ng-model="formValues.RepositoryMechanism" uib-btn-radio="'Interval'">Polling</label>
<label class="btn btn-primary" ng-model="formValues.RepositoryMechanism" uib-btn-radio="'Webhook'">Webhook</label>
</div>
</div>
</div>
</div>
<div class="form-group" ng-if="formValues.RepositoryMechanism === 'Webhook'">
<label for="repository_mechanism" class="col-sm-2 control-label text-left">
Webhook
</label>
<div class="col-sm-3">
<span class="text-muted"> {{ formValues.RepositoryWebhookURL | truncatelr }} </span>
<button type="button" class="btn btn-sm btn-primary btn-sm space-left" ng-if="formValues.RepositoryWebhookURL" ng-click="copyWebhook()">
<span><i class="fa fa-copy space-right" aria-hidden="true"></i>Copy link</span>
</button>
<span>
<i id="copyNotification" class="fa fa-check green-icon" aria-hidden="true" style="margin-left: 7px; display: none;"></i>
</span>
</div>
</div>
<div class="form-group" ng-if="formValues.RepositoryMechanism === 'Interval'">
<label for="repository_fetch_interval" class="col-sm-2 control-label text-left">
Fetch interval
</label>
<div class="col-sm-10">
<input type="text" class="form-control" ng-model="formValues.RepositoryFetchInterval" name="repository_fetch_interval" placeholder="5m" required interval-format />
</div>
</div>
<div class="form-group col-md-12" ng-show="createStackForm.repository_fetch_interval.$invalid">
<div class="small text-warning">
<div ng-messages="createStackForm.repository_fetch_interval.$error">
<p ng-message="required"> <i class="fa fa-exclamation-triangle" aria-hidden="true"></i> This field is required.</p>
<p ng-message="invalidIntervalFormat"> <i class="fa fa-exclamation-triangle" aria-hidden="true"></i> Please enter a valid time interval.</p>
<p ng-message="minimumInterval"> <i class="fa fa-exclamation-triangle" aria-hidden="true"></i> Minimum interval is 1m</p>
</div>
</div>
</div>
</div>

View File

@@ -15,83 +15,275 @@
<!-- tab-info -->
<uib-tab index="0">
<uib-tab-heading> <i class="fa fa-th-list" aria-hidden="true"></i> Stack </uib-tab-heading>
<div style="margin-top: 10px;">
<!-- stack-information -->
<div ng-if="state.externalStack">
<div class="col-sm-12 form-section-title">
Information
<form class="form-horizontal" name="editStackForm">
<div style="margin-top: 10px;">
<!-- stack-information -->
<div ng-if="state.externalStack">
<div class="col-sm-12 form-section-title">
Information
</div>
<div class="form-group">
<span class="small">
<p class="text-muted">
<i class="fa fa-exclamation-circle orange-icon" aria-hidden="true" style="margin-right: 2px;"></i>
This stack was created outside of Portainer. Control over this stack is limited.
</p>
</span>
</div>
</div>
<div class="form-group">
<span class="small">
<p class="text-muted">
<i class="fa fa-exclamation-circle orange-icon" aria-hidden="true" style="margin-right: 2px;"></i>
This stack was created outside of Portainer. Control over this stack is limited.
</p>
</span>
<!-- !stack-information -->
<!-- stack-details -->
<div>
<div class="col-sm-12 form-section-title">
Stack details
</div>
<div class="form-group">
<div class="col-sm-12">
{{ stackName }}
<button
authorization="PortainerStackUpdate"
ng-if="!state.externalStack && stack.Status === 2"
ng-disabled="state.actionInProgress"
class="btn btn-xs btn-success"
ng-click="startStack()"
>
<i class="fa fa-play space-right" aria-hidden="true"></i>
Start this stack
</button>
<button
ng-if="!state.externalStack && stack.Status === 1"
authorization="PortainerStackUpdate"
ng-disabled="state.actionInProgress"
class="btn btn-xs btn-danger"
ng-click="stopStack()"
>
<i class="fa fa-stop space-right" aria-hidden="true"></i>
Stop this stack
</button>
<button authorization="PortainerStackDelete" class="btn btn-xs btn-danger" ng-click="removeStack()" ng-if="!state.externalStack || stack.Type === 1">
<i class="fa fa-trash-alt space-right" aria-hidden="true"></i>
Delete this stack
</button>
<button
ng-if="!state.externalStack && stackFileContent"
class="btn btn-primary btn-xs"
ui-sref="docker.templates.custom.new({fileContent: stackFileContent, type: stack.Type})"
>
<i class="fa fa-plus space-right" aria-hidden="true"></i>
Create template from stack
</button>
</div>
</div>
</div>
<!-- !stack-details -->
<!-- git stack-details -->
<div ng-if="state.isGitStack">
<div class="form-group">
<span class="col-sm-12 text-muted small">
This stack was deployed from the git repository <code>{{ stack.GitConfig.URL }}</code>
</span>
<span class="col-sm-12 text-muted small">
Update <code>{{ stack.GitConfig.ConfigFilePath }}</code> in git and pull from here to update the stack
</span>
</div>
<div class="col-sm-12 form-section-title">
Redeploy from git repository
</div>
<div class="form-group">
<span class="col-sm-12 text-muted small">
Pull the latest Compose file from git and redeploy the stack
</span>
</div>
<div class="form-group">
<div class="col-sm-12">
<label class="control-label text-left">
Automatic updates
</label>
<label class="switch" style="margin-left: 20px;"> <input type="checkbox" ng-model="formValues.RepositoryAutomaticUpdates" /><i></i> </label>
</div>
</div>
<div class="small text-warning" style="margin-top: 5px;" ng-if="formValues.RepositoryAutomaticUpdates">
<i class="fa fa-exclamation-circle" aria-hidden="true"></i>
<span class="text-muted">Enabling automatic updates will store the credentials and it is advisable to use a git service account.</span>
</div>
<div class="small text-warning" style="margin: 5px 0 10px 0;" ng-if="formValues.RepositoryAutomaticUpdates">
<i class="fa fa-exclamation-circle" aria-hidden="true"></i>
<span class="text-muted"
>Any changes to this stack made locally in Portainer will be overriden by the definition in git and may cause service interruption.</span
>
</div>
<div class="form-group" ng-if="formValues.RepositoryAutomaticUpdates">
<label for="repository_mechanism" class="col-sm-2 control-label text-left">
Mechanism
</label>
<div class="col-sm-10">
<div class="input-group col-sm-10 input-group-sm">
<div class="btn-group btn-group-sm">
<label class="btn btn-primary" ng-model="formValues.RepositoryMechanism" uib-btn-radio="'Interval'">Polling</label>
<label class="btn btn-primary" ng-model="formValues.RepositoryMechanism" uib-btn-radio="'Webhook'">Webhook</label>
</div>
</div>
</div>
</div>
<div class="form-group" ng-if="formValues.RepositoryAutomaticUpdates && formValues.RepositoryMechanism === 'Webhook'">
<label for="repository_mechanism" class="col-sm-2 control-label text-left">
Webhook
</label>
<div class="col-sm-10">
<span class="text-muted"> {{ formValues.RepositoryWebhookURL | truncatelr }} </span>
<button type="button" class="btn btn-sm btn-primary btn-sm space-left" ng-if="formValues.RepositoryWebhookURL" ng-click="copyWebhook()">
<span><i class="fa fa-copy space-right" aria-hidden="true"></i>Copy link</span>
</button>
<span>
<i id="copyNotification" class="fa fa-check green-icon" aria-hidden="true" style="margin-left: 7px; display: none;"></i>
</span>
</div>
</div>
<div class="form-group" ng-if="formValues.RepositoryAutomaticUpdates && formValues.RepositoryMechanism === 'Interval'">
<label for="repository_fetch_interval" class="col-sm-2 control-label text-left">
Fetch interval
</label>
<div class="col-sm-10">
<input
type="text"
class="form-control"
ng-model="formValues.RepositoryFetchInterval"
name="repository_fetch_interval"
placeholder="5m"
required
interval-format
/>
</div>
</div>
<div class="form-group col-md-12" ng-show="editStackForm.repository_fetch_interval.$invalid">
<div class="small text-warning">
<div ng-messages="editStackForm.repository_fetch_interval.$error">
<p ng-message="required"> <i class="fa fa-exclamation-triangle" aria-hidden="true"></i> This field is required.</p>
<p ng-message="invalidIntervalFormat"> <i class="fa fa-exclamation-triangle" aria-hidden="true"></i> Please enter a valid time interval.</p>
<p ng-message="minimumInterval"> <i class="fa fa-exclamation-triangle" aria-hidden="true"></i> Minimum interval is 1m</p>
</div>
</div>
</div>
<div ng-show="!formValues.RepositoryAutomaticUpdates">
<div class="form-group">
<div class="col-sm-12">
<label class="control-label text-left">
Authentication
</label>
<label class="switch" style="margin-left: 20px;"> <input type="checkbox" ng-model="formValues.RepositoryAuthentication" /><i></i> </label>
</div>
</div>
<div class="form-group" ng-if="formValues.RepositoryAuthentication">
<label for="repository_username" class="col-sm-2 control-label text-left">Username</label>
<div class="col-sm-3">
<input type="text" class="form-control" ng-model="formValues.RepositoryUsername" name="repository_username" placeholder="git username" />
</div>
</div>
<div class="form-group" ng-if="formValues.RepositoryAuthentication">
<label for="repository_personal_access_token" class="col-sm-2 control-label text-left">
Personal Access Token
<portainer-tooltip position="bottom" message="Provide a personal access token or password"></portainer-tooltip>
</label>
<div class="col-sm-3">
<input
type="password"
class="form-control"
ng-model="formValues.RepositoryPersonalAccessToken"
name="repository_personal_access_token"
placeholder="personal access token"
/>
</div>
</div>
</div>
<div class="form-group">
<div class="col-sm-12">
<p>
<a class="small interactive" ng-if="!state.showConfig" ng-click="state.showConfig = true">
<i class="fa fa-plus space-right" aria-hidden="true"></i> Advanced configuration
</a>
<a class="small interactive" ng-if="state.showConfig" ng-click="state.showConfig = false">
<i class="fa fa-minus space-right" aria-hidden="true"></i> Hide configuration
</a>
</p>
</div>
</div>
<div class="form-group" ng-show="state.showConfig">
<span class="col-sm-12 text-muted small">
Specify a reference of the repository using the following syntax branches with <code>refs/heads/branch_name</code> or tags with
<code>refs/tags/tag_name</code>
</span>
<span class="col-sm-12 text-muted small"> If not specified, will use the default <code>HEAD</code> reference normally the <code>master</code> branch.</span>
</div>
<div class="form-group" ng-show="state.showConfig">
<label for="stack_repository_url" class="col-sm-2 control-label text-left">Repository reference</label>
<div class="col-sm-10">
<input type="text" class="form-control" ng-model="formValues.RepositoryReferenceName" id="stack_repository_reference_name" placeholder="refs/heads/master" />
</div>
</div>
<!-- environment-variables -->
<div class="col-sm-12 form-section-title">
Environment
</div>
<div class="form-group">
<div class="col-sm-12" style="margin-top: 5px;" authorization="PortainerStackUpdate">
<label class="control-label text-left">Environment variables</label>
<span class="label label-default interactive" style="margin-left: 10px;" ng-click="addEnvironmentVariable()">
<i class="fa fa-plus-circle" aria-hidden="true"></i> add environment variable
</span>
</div>
<!-- environment-variable-input-list -->
<div class="col-sm-12 form-inline" style="margin-top: 10px;">
<div ng-repeat="variable in stack.Env" style="margin-top: 2px;">
<div class="input-group col-sm-5 input-group-sm">
<span class="input-group-addon">name</span>
<input type="text" class="form-control" ng-model="variable.name" placeholder="e.g. FOO" disable-authorization="PortainerStackUpdate" />
</div>
<div class="input-group col-sm-5 input-group-sm">
<span class="input-group-addon">value</span>
<input type="text" class="form-control" ng-model="variable.value" placeholder="e.g. bar" disable-authorization="PortainerStackUpdate" />
</div>
<button class="btn btn-sm btn-danger" type="button" ng-click="removeEnvironmentVariable($index)" authorization="PortainerStackUpdate">
<i class="fa fa-trash" aria-hidden="true"></i>
</button>
</div>
</div>
<!-- !environment-variable-input-list -->
</div>
<!-- !environment-variables -->
<div class="form-group">
<div class="col-sm-12">
<button ng-if="formValues.RepositoryAutomaticUpdates" ng-click="saveGitSettings()" class="btn btn-sm btn-primary" button-spinner="state.redeployInProgress">
<span>Save settings</span>
</button>
<button ng-if="!formValues.RepositoryAutomaticUpdates" ng-click="gitRedeploy()" class="btn btn-sm btn-primary" button-spinner="state.redeployInProgress">
<i class="fa fa-sync space-right" aria-hidden="true"></i>Pull and redeploy
</button>
</div>
</div>
</div>
<!-- !git stack-details -->
<stack-duplication-form
ng-if="!state.externalStack && endpoints.length > 0"
endpoints="endpoints"
groups="groups"
current-endpoint-id="currentEndpointId"
on-duplicate="duplicateStack(name, endpointId)"
on-migrate="migrateStack(name, endpointId)"
yaml-error="state.yamlError"
>
</stack-duplication-form>
</div>
<!-- !stack-information -->
<!-- stack-details -->
<div>
<div class="col-sm-12 form-section-title">
Stack details
</div>
<div class="form-group">
{{ stackName }}
<button
authorization="PortainerStackUpdate"
ng-if="!state.externalStack && stack.Status === 2"
ng-disabled="state.actionInProgress"
class="btn btn-xs btn-success"
ng-click="startStack()"
>
<i class="fa fa-play space-right" aria-hidden="true"></i>
Start this stack
</button>
<button
ng-if="!state.externalStack && stack.Status === 1"
authorization="PortainerStackUpdate"
ng-disabled="state.actionInProgress"
class="btn btn-xs btn-danger"
ng-click="stopStack()"
>
<i class="fa fa-stop space-right" aria-hidden="true"></i>
Stop this stack
</button>
<button authorization="PortainerStackDelete" class="btn btn-xs btn-danger" ng-click="removeStack()" ng-if="!state.externalStack || stack.Type === 1">
<i class="fa fa-trash-alt space-right" aria-hidden="true"></i>
Delete this stack
</button>
<button
ng-if="!state.externalStack && stackFileContent"
class="btn btn-primary btn-xs"
ui-sref="docker.templates.custom.new({fileContent: stackFileContent, type: stack.Type})"
>
<i class="fa fa-plus space-right" aria-hidden="true"></i>
Create template from stack
</button>
</div>
</div>
<!-- !stack-details -->
<stack-duplication-form
ng-if="!state.externalStack && endpoints.length > 0"
endpoints="endpoints"
groups="groups"
current-endpoint-id="currentEndpointId"
on-duplicate="duplicateStack(name, endpointId)"
on-migrate="migrateStack(name, endpointId)"
yaml-error="state.yamlError"
>
</stack-duplication-form>
</div>
</form>
</uib-tab>
<!-- !tab-info -->
<!-- tab-file -->
<uib-tab index="1" select="showEditor()" ng-if="!state.externalStack">
<uib-tab index="1" select="showEditor()" ng-if="!state.externalStack && !state.isGitStack">
<uib-tab-heading> <i class="fa fa-pencil-alt space-right" aria-hidden="true"></i> Editor </uib-tab-heading>
<form class="form-horizontal" ng-if="state.showEditorTab" style="margin-top: 10px;">
<div class="form-group">

View File

@@ -20,6 +20,8 @@ angular.module('portainer.app').controller('StackController', [
'ModalService',
'StackHelper',
'ContainerHelper',
'WebhookHelper',
'clipboard',
function (
$async,
$q,
@@ -41,22 +43,71 @@ angular.module('portainer.app').controller('StackController', [
GroupService,
ModalService,
StackHelper,
ContainerHelper
ContainerHelper,
WebhookHelper,
clipboard
) {
$scope.state = {
actionInProgress: false,
migrationInProgress: false,
redeployInProgress: false,
externalStack: false,
showEditorTab: false,
yamlError: false,
isEditorDirty: false,
showConfig: true,
isGitStack: false,
};
$scope.formValues = {
RepositoryAutomaticUpdates: false,
RepositoryAuthentication: true,
RepositoryMechanism: 'Interval',
RepositoryWebhookURL: '',
Env: [],
Prune: false,
Endpoint: null,
};
$scope.copyWebhook = function () {
clipboard.copyText($scope.formValues.RepositoryWebhookURL);
$('#copyNotification').show();
$('#copyNotification').fadeOut(2000);
};
$scope.saveGitSettings = function () {
const payload = {
AutoUpdate: {},
Env: FormHelper.removeInvalidEnvVars($scope.stack.Env),
RepositoryReferenceName: $scope.formValues.RepositoryReferenceName,
};
if ($scope.formValues.RepositoryMechanism === 'Interval') {
payload.AutoUpdate.Interval = $scope.formValues.RepositoryFetchInterval;
} else if ($scope.formValues.RepositoryMechanism === 'Webhook') {
payload.AutoUpdate.Webhook = $scope.formValues.RepositoryWebhookURL.split('/').reverse()[0];
}
StackService.updateGitStack($scope.stack.Id, $scope.stack.EndpointId, payload);
};
$scope.gitRedeploy = function () {
ModalService.confirmRedeployStackViaGit(function (confirmed) {
if (!confirmed) {
return;
}
const payload = {
RepositoryReferenceName: $scope.formValues.RepositoryReferenceName,
RepositoryAuthentication: $scope.formValues.RepositoryAuthentication,
RepositoryUsername: $scope.formValues.RepositoryUsername,
RepositoryPassword: $scope.formValues.RepositoryPersonalAccessToken,
};
StackService.redeployGitStack($scope.stack.Id, $scope.stack.EndpointId, payload);
});
};
$window.onbeforeunload = () => {
if ($scope.stackFileContent && $scope.state.isEditorDirty) {
return '';
@@ -270,6 +321,22 @@ angular.module('portainer.app').controller('StackController', [
$scope.stack = stack;
$scope.containerNames = ContainerHelper.getContainerNames(data.containers);
if ($scope.stack.GitConfig && $scope.stack.GitConfig.URL) {
$scope.state.isGitStack = true;
$scope.formValues.RepositoryReferenceName = $scope.stack.GitConfig.ReferenceName;
if ($scope.stack.AutoUpdate && ($scope.stack.AutoUpdate.Interval || $scope.stack.AutoUpdate.Webhook)) {
$scope.formValues.RepositoryAutomaticUpdates = true;
if ($scope.stack.AutoUpdate.Interval) {
$scope.formValues.RepositoryMechanism = $scope.stack.AutoUpdate.Interval;
} else if ($scope.stack.AutoUpdate.Webhook) {
$scope.formValues.RepositoryMechanism = $scope.stack.AutoUpdate.Webhook;
$scope.formValues.RepositoryWebhookURL = WebhookHelper.returnStackWebhookUrl($scope.stack.AutoUpdate.Webhook);
}
}
}
let resourcesPromise = Promise.resolve({});
if (stack.Status === 1) {
resourcesPromise = stack.Type === 1 ? retrieveSwarmStackResources(stack.Name, agentProxy) : retrieveComposeStackResources(stack.Name);

View File

@@ -91,6 +91,7 @@
"lodash-es": "^4.17.15",
"moment": "^2.21.0",
"ng-file-upload": "~12.2.13",
"parse-duration": "^1.0.0",
"source-map-loader": "^1.1.2",
"spinkit": "^2.0.1",
"splitargs": "github:deviantony/splitargs#semver:~0.2.0",

View File

@@ -7967,6 +7967,11 @@ parse-asn1@^5.0.0, parse-asn1@^5.1.5:
pbkdf2 "^3.0.3"
safe-buffer "^5.1.1"
parse-duration@^1.0.0:
version "1.0.0"
resolved "https://registry.yarnpkg.com/parse-duration/-/parse-duration-1.0.0.tgz#8605651745f61088f6fb14045c887526c291858c"
integrity sha512-X4kUkCTHU1N/kEbwK9FpUJ0UZQa90VzeczfS704frR30gljxDG0pSziws06XlK+CGRSo/1wtG1mFIdBFQTMQNw==
parse-filepath@^1.0.1:
version "1.0.2"
resolved "https://registry.yarnpkg.com/parse-filepath/-/parse-filepath-1.0.2.tgz#a632127f53aaf3d15876f5872f3ffac763d6c891"