1
0
Fork 0
mirror of https://github.com/portainer/portainer.git synced 2025-08-08 07:15:23 +02:00

feat(custom-templates): introduce custom templates (#3906)

* feat(custom-templates): introduce types

* feat(custom-templates): introduce data layer service

* feat(custom-templates): introduce http handler

* feat(custom-templates): create routes and view stubs

* feat(custom-templates): add create custom template ui

* feat(custom-templates): add json keys

* feat(custom-templates): introduce custom templates list page

* feat(custom-templates): introduce update page

* feat(stack): create template from stack

* feat(stacks): create stack from custom template

* feat(custom-templates): disable edit/delete of templates

* fix(custom-templates): fail update on non admin/owner

* fix(custom-templates): add ng-inject decorator

* chore(plop): revert template

* feat(stacks): remove actions column

* feat(stack): add button to create template from stack

* feat(stacks): add empty state for templates

* feat(custom-templates): show templates in a list

* feat(custom-template): replace table with list

* feat(custom-templates): move create template button

* refactor(custom-templates): introduce more fields

* feat(custom-templates): use stack type when creating template

* feat(custom-templates): use same type as stack

* feat(custom-templates): add edit and delete buttons to template item

* feat(custom-templates): customize stack before deploy

* feat(stack): show template details

* feat(custom-templates): move customize

* feat(custom-templates): create description required

* fix(template): show platform icon

* fix(custom-templates): show spinner when creating stack

* feat(custom-templates): prevent user from edit templates

* feat(custom-templates): use resource control for custom templates

* feat(custom-templates): show created templates

* feat(custom-templates): filter templates by stack type

* fix(custom-templates): create swarm or standalone stack

* feat(stacks): filter templates by type

* feat(resource-control): disable resource control on public

* feat(custom-template): apply access control on edit

* feat(custom-template): add form validation

* feat(stack): disable create custom template from external task

* refactor(custom-templates): create template from file and type

* feat(templates): introduce a file handler that returns template docker file

* feat(template): introduce template duplication

* feat(custom-template): enforce unique template name

* fix(template): rename copy button

* fix(custom-template): clear access control selection between templates

* fix(custom-templates): show required fields

* refactor(filesystem): use a constant for temp path
This commit is contained in:
Chaim Lev-Ari 2020-07-07 02:18:39 +03:00 committed by GitHub
parent 42aa8ceb00
commit 53b37ab8c8
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
58 changed files with 2513 additions and 154 deletions

View file

@ -514,6 +514,43 @@ angular.module('portainer.app', []).config([
},
};
const customTemplates = {
name: 'portainer.templates.custom',
url: '/custom',
views: {
'content@': {
component: 'customTemplatesView',
},
},
};
const customTemplatesNew = {
name: 'portainer.templates.custom.new',
url: '/new?fileContent&type',
views: {
'content@': {
component: 'createCustomTemplateView',
},
},
params: {
fileContent: '',
type: '',
},
};
const customTemplatesEdit = {
name: 'portainer.templates.custom.edit',
url: '/:id',
views: {
'content@': {
component: 'editCustomTemplateView',
},
},
};
$stateRegistryProvider.register(root);
$stateRegistryProvider.register(portainer);
$stateRegistryProvider.register(about);
@ -552,5 +589,8 @@ angular.module('portainer.app', []).config([
$stateRegistryProvider.register(teams);
$stateRegistryProvider.register(team);
$stateRegistryProvider.register(templates);
$stateRegistryProvider.register(customTemplates);
$stateRegistryProvider.register(customTemplatesNew);
$stateRegistryProvider.register(customTemplatesEdit);
},
]);

View file

@ -22,6 +22,10 @@ angular.module('portainer.app').controller('porAccessControlFormController', [
} else {
ctrl.formData.Ownership = resourceControl.Ownership;
}
if (ctrl.formData.Ownership === RCO.PUBLIC) {
ctrl.formData.AccessControlEnabled = false;
}
}
function setAuthorizedUsersAndTeams(authorizedUsers, authorizedTeams) {

View file

@ -0,0 +1,71 @@
<ng-form name="commonCustomTemplateForm">
<!-- title-input -->
<div class="form-group">
<label for="template_title" class="col-sm-3 col-lg-2 control-label text-left">
Title
</label>
<div class="col-sm-9 col-lg-10">
<input type="text" class="form-control" ng-model="$ctrl.formValues.Title" id="template_title" name="template_title" placeholder="e.g. mytemplate" auto-focus required />
</div>
</div>
<div class="form-group" ng-show="commonCustomTemplateForm.template_title.$invalid">
<div class="col-sm-12 small text-warning">
<div ng-messages="commonCustomTemplateForm.template_title.$error">
<p ng-message="required"> <i class="fa fa-exclamation-triangle" aria-hidden="true"></i> Title is required.</p>
</div>
</div>
</div>
<!-- !title-input -->
<!-- description-input -->
<div class="form-group">
<label for="description" class="col-sm-3 col-lg-2 control-label text-left">Description</label>
<div class="col-sm-9 col-lg-10">
<input type="text" class="form-control" id="description" ng-model="$ctrl.formValues.Description" name="description" required />
</div>
</div>
<div class="form-group" ng-show="commonCustomTemplateForm.description.$invalid">
<div class="col-sm-12 small text-warning">
<div ng-messages="commonCustomTemplateForm.description.$error">
<p ng-message="required"> <i class="fa fa-exclamation-triangle" aria-hidden="true"></i> Description is required.</p>
</div>
</div>
</div>
<!-- !description-input -->
<!-- note-input -->
<div class="form-group">
<label for="note" class="col-sm-3 col-lg-2 control-label text-left">Note</label>
<div class="col-sm-9 col-lg-10">
<input type="text" class="form-control" id="note" ng-model="$ctrl.formValues.Note" />
</div>
</div>
<!-- !note-input -->
<!-- icon-url-input -->
<div class="form-group">
<label for="icon-url" class="col-sm-3 col-lg-2 control-label text-left">Icon URL</label>
<div class="col-sm-9 col-lg-10">
<input type="text" class="form-control" id="icon-url" ng-model="$ctrl.formValues.Logo" />
</div>
</div>
<!-- !icon-url-input -->
<!-- platform-input -->
<div class="form-group">
<label for="platform" class="col-sm-3 col-lg-2 control-label text-left">Platform</label>
<div class="col-sm-9 col-lg-10">
<select class="form-control" ng-model="$ctrl.formValues.Platform" ng-options="+(opt.value) as opt.label for opt in $ctrl.platformTypes"> </select>
</div>
</div>
<!-- !platform-input -->
<!-- platform-input -->
<div class="form-group">
<label for="platform" class="col-sm-3 col-lg-2 control-label text-left">Type</label>
<div class="col-sm-9 col-lg-10">
<select class="form-control" ng-model="$ctrl.formValues.Type" ng-options="+(opt.value) as opt.label for opt in $ctrl.templateTypes"> </select>
</div>
</div>
<!-- !platform-input -->
</ng-form>

View file

@ -0,0 +1,16 @@
class CustomTemplateCommonFieldsController {
/* @ngInject */
constructor() {
this.platformTypes = [
{ label: 'Linux', value: 1 },
{ label: 'Windows', value: 2 },
];
this.templateTypes = [
{ label: 'Swarm', value: 1 },
{ label: 'Standalone', value: 2 },
];
}
}
export default CustomTemplateCommonFieldsController;

View file

@ -0,0 +1,9 @@
import CustomTemplateCommonFieldsController from './customTemplateCommonFieldsController.js';
angular.module('portainer.app').component('customTemplateCommonFields', {
templateUrl: './customTemplateCommonFields.html',
controller: CustomTemplateCommonFieldsController,
bindings: {
formValues: '=',
},
});

View file

@ -0,0 +1,51 @@
<div class="datatable">
<rd-widget>
<rd-widget-body classes="no-padding">
<div class="toolBar">
<div class="toolBarTitle"> <i class="fa" ng-class="$ctrl.titleIcon" aria-hidden="true" style="margin-right: 2px;"></i> {{ $ctrl.titleText }} </div>
</div>
<div class="actionBar">
<button type="button" class="btn btn-sm btn-primary" ui-sref="portainer.templates.custom.new">
<i class="fa fa-plus space-right" aria-hidden="true"></i>Add Custom Template
</button>
</div>
<div class="searchBar" style="border-top: 2px solid #f6f6f6;">
<i class="fa fa-search searchIcon" aria-hidden="true"></i>
<input
type="text"
class="searchInput"
ng-model="$ctrl.state.textFilter"
ng-change="$ctrl.onTextFilterChange()"
placeholder="Search..."
auto-focus
ng-model-options="{ debounce: 300 }"
/>
</div>
<div class="blocklist">
<template-item
ng-repeat="template in $ctrl.templates | filter:$ctrl.state.textFilter"
model="template"
type-label="{{ template.Type === 1 ? 'swarm' : 'standalone' }}"
on-select="($ctrl.onSelectClick)"
>
<template-item-actions>
<div ng-if="$ctrl.isEditAllowed(template)" style="display: flex;">
<a ui-sref="portainer.templates.custom.edit({id: template.Id})" ng-click="$event.stopPropagation();" class="btn btn-primary btn-xs" style="margin-right: 10px;">
Edit
</a>
<button class="btn btn-danger btn-xs" ng-click="$ctrl.onDeleteClick(template.Id); $event.stopPropagation();">Delete</button>
</div>
</template-item-actions>
</template-item>
<div ng-if="!$ctrl.templates" class="text-center text-muted">
Loading...
</div>
<div ng-if="($ctrl.templates | filter: $ctrl.state.textFilter).length === 0" class="text-center text-muted">
No templates available.
</div>
</div>
</rd-widget-body>
</rd-widget>
</div>

View file

@ -0,0 +1,15 @@
import angular from 'angular';
angular.module('portainer.app').component('customTemplatesList', {
templateUrl: './customTemplatesList.html',
bindings: {
titleText: '@',
titleIcon: '@',
templates: '<',
tableKey: '@',
onSelectClick: '<',
showSwarmStacks: '<',
onDeleteClick: '<',
isEditAllowed: '<',
},
});

View file

@ -0,0 +1,15 @@
import angular from 'angular';
angular.module('portainer.app').component('stackFromTemplateForm', {
templateUrl: './stackFromTemplateForm.html',
bindings: {
template: '=',
formValues: '=',
state: '=',
createTemplate: '<',
unselectTemplate: '<',
},
transclude: {
advanced: '?advancedForm',
},
});

View file

@ -0,0 +1,73 @@
<div class="col-sm-12">
<rd-widget>
<rd-widget-custom-header icon="$ctrl.template.Logo" title-text="$ctrl.template.Title"></rd-widget-custom-header>
<rd-widget-body classes="padding">
<form class="form-horizontal">
<!-- description -->
<div ng-if="$ctrl.template.Note">
<div class="col-sm-12 form-section-title">
Information
</div>
<div class="form-group">
<div class="col-sm-12">
<div class="template-note" ng-bind-html="$ctrl.template.Note"></div>
</div>
</div>
</div>
<!-- !description -->
<div class="col-sm-12 form-section-title">
Configuration
</div>
<!-- name-input -->
<div class="form-group">
<label for="container_name" class="col-sm-2 control-label text-left">Name</label>
<div class="col-sm-10">
<input type="text" name="container_name" class="form-control" ng-model="$ctrl.formValues.name" placeholder="e.g. myStack" required />
</div>
</div>
<!-- !name-input -->
<!-- env -->
<div ng-repeat="var in $ctrl.template.Env" ng-if="!var.preset || var.select" class="form-group">
<label for="field_{{ $index }}" class="col-sm-2 control-label text-left">
{{ var.label }}
<portainer-tooltip ng-if="var.description" position="bottom" message="{{ var.description }}"></portainer-tooltip>
</label>
<div class="col-sm-10">
<input type="text" class="form-control" ng-if="!var.select" ng-model="var.value" id="field_{{ $index }}" />
<select class="form-control" ng-if="var.select" ng-model="var.value" id="field_{{ $index }}">
<option selected disabled hidden value="">Select value</option>
<option ng-repeat="choice in var.select" value="{{ choice.value }}">{{ choice.text }}</option>
</select>
</div>
</div>
<!-- !env -->
<ng-transclude ng-transclude-slot="advanced"></ng-transclude>
<!-- access-control -->
<por-access-control-form form-data="$ctrl.formValues.AccessControlData"></por-access-control-form>
<!-- !access-control -->
<!-- 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.formValues.name"
ng-click="$ctrl.createTemplate()"
button-spinner="$ctrl.state.actionInProgress"
>
<span ng-hide="$ctrl.state.actionInProgress">Deploy the stack</span>
<span ng-show="$ctrl.state.actionInProgress">Deployment in progress...</span>
</button>
<button type="button" class="btn btn-sm btn-default" ng-click="$ctrl.unselectTemplate($ctrl.template)">Hide</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>

View file

@ -0,0 +1,9 @@
.template-item-details {
display: flex;
justify-content: space-between;
flex-wrap: wrap;
}
.template-item-details .template-item-details-sub {
width: 100%;
}

View file

@ -1,7 +1,15 @@
import angular from 'angular';
import './template-item.css';
angular.module('portainer.app').component('templateItem', {
templateUrl: './templateItem.html',
bindings: {
model: '=',
model: '<',
typeLabel: '@',
onSelect: '<',
},
transclude: {
actions: '?templateItemActions',
},
});

View file

@ -1,5 +1,5 @@
<!-- template -->
<div ng-class="{ 'blocklist-item--selected': $ctrl.model.Selected }" class="blocklist-item" ng-click="$ctrl.onSelect($ctrl.model)">
<div ng-class="{ 'blocklist-item--selected': $ctrl.model.Selected }" class="blocklist-item template-item" ng-click="$ctrl.onSelect($ctrl.model)">
<div class="blocklist-item-box">
<!-- template-image -->
<span ng-if="$ctrl.model.Logo">
@ -10,7 +10,7 @@
</span>
<!-- !template-image -->
<!-- template-details -->
<span class="col-sm-12">
<div class="col-sm-12 template-item-details">
<!-- blocklist-item-line1 -->
<div class="blocklist-item-line">
<span>
@ -19,19 +19,20 @@
</span>
<span class="space-left blocklist-item-subtitle">
<span>
<i class="fab fa-linux" aria-hidden="true" ng-if="$ctrl.model.Platform === 'linux' || !$ctrl.model.Platform"></i>
<i class="fab fa-linux" aria-hidden="true" ng-if="$ctrl.model.Platform === 1 || $ctrl.model.Platform === 'linux' || !$ctrl.model.Platform"></i>
<span ng-if="!$ctrl.model.Platform"> &amp; </span>
<i class="fab fa-windows" aria-hidden="true" ng-if="$ctrl.model.Platform === 'windows' || !$ctrl.model.Platform"></i>
<i class="fab fa-windows" aria-hidden="true" ng-if="$ctrl.model.Platform === 2 || $ctrl.model.Platform === 'windows' || !$ctrl.model.Platform"></i>
</span>
<span>
{{ $ctrl.model.Type === 1 ? 'container' : 'stack' }}
{{ $ctrl.typeLabel }}
</span>
</span>
</span>
</div>
<!-- !blocklist-item-line1 -->
<span class="blocklist-item-actions" ng-transclude="actions"></span>
<!-- blocklist-item-line2 -->
<div class="blocklist-item-line">
<div class="blocklist-item-line template-item-details-sub">
<span class="blocklist-item-desc">
{{ $ctrl.model.Description }}
</span>
@ -40,7 +41,7 @@
</span>
</div>
<!-- !blocklist-item-line2 -->
</span>
</div>
<!-- !template-details -->
</div>
<!-- !template -->

View file

@ -1,59 +1,80 @@
import _ from 'lodash-es';
angular.module('portainer.app').controller('TemplateListController', [
'DatatableService',
function TemplateListController(DatatableService) {
var ctrl = this;
angular.module('portainer.app').controller('TemplateListController', TemplateListController);
this.state = {
textFilter: '',
selectedCategory: '',
categories: [],
showContainerTemplates: true,
};
function TemplateListController($async, $state, DatatableService, Notifications, TemplateService) {
var ctrl = this;
this.onTextFilterChange = function () {
DatatableService.setDataTableTextFilters(this.tableKey, this.state.textFilter);
};
this.state = {
textFilter: '',
selectedCategory: '',
categories: [],
showContainerTemplates: true,
};
this.updateCategories = function () {
var availableCategories = [];
this.onTextFilterChange = function () {
DatatableService.setDataTableTextFilters(this.tableKey, this.state.textFilter);
};
for (var i = 0; i < ctrl.templates.length; i++) {
var template = ctrl.templates[i];
if ((template.Type === 1 && ctrl.state.showContainerTemplates) || (template.Type === 2 && ctrl.showSwarmStacks) || (template.Type === 3 && !ctrl.showSwarmStacks)) {
availableCategories = availableCategories.concat(template.Categories);
}
this.updateCategories = function () {
var availableCategories = [];
for (var i = 0; i < ctrl.templates.length; i++) {
var template = ctrl.templates[i];
if ((template.Type === 1 && ctrl.state.showContainerTemplates) || (template.Type === 2 && ctrl.showSwarmStacks) || (template.Type === 3 && !ctrl.showSwarmStacks)) {
availableCategories = availableCategories.concat(template.Categories);
}
}
this.state.categories = _.sortBy(_.uniq(availableCategories));
};
this.state.categories = _.sortBy(_.uniq(availableCategories));
};
this.filterByCategory = function (item) {
if (!ctrl.state.selectedCategory) {
return true;
this.filterByCategory = function (item) {
if (!ctrl.state.selectedCategory) {
return true;
}
return _.includes(item.Categories, ctrl.state.selectedCategory);
};
this.filterByType = function (item) {
if ((item.Type === 1 && ctrl.state.showContainerTemplates) || (item.Type === 2 && ctrl.showSwarmStacks) || (item.Type === 3 && !ctrl.showSwarmStacks)) {
return true;
}
return false;
};
this.duplicateTemplate = duplicateTemplate.bind(this);
this.duplicateTemplateAsync = duplicateTemplateAsync.bind(this);
function duplicateTemplate(template) {
return $async(this.duplicateTemplateAsync, template);
}
async function duplicateTemplateAsync(template) {
try {
const { FileContent: fileContent } = await TemplateService.templateFile(template.Repository.url, template.Repository.stackfile);
let type = 0;
if (template.Type === 2) {
type = 1;
}
return _.includes(item.Categories, ctrl.state.selectedCategory);
};
this.filterByType = function (item) {
if ((item.Type === 1 && ctrl.state.showContainerTemplates) || (item.Type === 2 && ctrl.showSwarmStacks) || (item.Type === 3 && !ctrl.showSwarmStacks)) {
return true;
if (template.Type === 3) {
type = 2;
}
return false;
};
$state.go('portainer.templates.custom.new', { fileContent, type });
} catch (err) {
Notifications.error('Failure', err, 'Failed to duplicate template');
}
}
this.$onInit = function () {
if (this.showSwarmStacks) {
this.state.showContainerTemplates = false;
}
this.updateCategories();
this.$onInit = function () {
if (this.showSwarmStacks) {
this.state.showContainerTemplates = false;
}
this.updateCategories();
var textFilter = DatatableService.getDataTableTextFilters(this.tableKey);
if (textFilter !== null) {
this.state.textFilter = textFilter;
}
};
},
]);
var textFilter = DatatableService.getDataTableTextFilters(this.tableKey);
if (textFilter !== null) {
this.state.textFilter = textFilter;
}
};
}

View file

@ -49,8 +49,15 @@
<template-item
ng-repeat="template in $ctrl.templates | filter: $ctrl.filterByType | filter:$ctrl.filterByCategory | filter:$ctrl.state.textFilter"
model="template"
type-label="{{ template.Type === 1 ? 'container' : 'stack' }}"
on-select="($ctrl.selectAction)"
></template-item>
>
<template-item-actions ng-if="template.Type === 2 || template.Type === 3">
<button ng-click="$event.stopPropagation(); $ctrl.duplicateTemplate(template)" class="btn btn-primary btn-xs">
Copy as Custom
</button>
</template-item-actions>
</template-item>
<div ng-if="!$ctrl.templates" class="text-center text-muted">
Loading...
</div>

View file

@ -1,4 +1,4 @@
import { ResourceControlOwnership as RCO } from 'Portainer/models/resourceControl/resourceControlOwnership';
import { ResourceControlOwnership as RCO } from './resourceControlOwnership';
export function ResourceControlViewModel(data) {
this.Id = data.Id;

View file

@ -6,6 +6,7 @@ export const ResourceControlTypeString = Object.freeze({
SERVICE: 'service',
STACK: 'stack',
VOLUME: 'volume',
CUSTOM_TEMPLATE: 'custom-template',
});
/**
@ -19,4 +20,5 @@ export const ResourceControlTypeInt = Object.freeze({
SECRET: 5,
STACK: 6,
CONFIG: 7,
CUSTOM_TEMPLATE: 8,
});

View file

@ -0,0 +1,18 @@
import angular from 'angular';
angular.module('portainer.app').factory('CustomTemplates', CustomTemplatesFactory);
function CustomTemplatesFactory($resource, API_ENDPOINT_CUSTOM_TEMPLATES) {
return $resource(
API_ENDPOINT_CUSTOM_TEMPLATES + '/:id/:action',
{},
{
create: { method: 'POST', ignoreLoadingBar: true },
query: { method: 'GET', isArray: true },
get: { method: 'GET', params: { id: '@id' } },
update: { method: 'PUT', params: { id: '@id' } },
remove: { method: 'DELETE', params: { id: '@id' } },
file: { method: 'GET', params: { id: '@id', action: 'file' } },
}
);
}

View file

@ -3,10 +3,11 @@ angular.module('portainer.app').factory('Templates', [
'API_ENDPOINT_TEMPLATES',
function TemplatesFactory($resource, API_ENDPOINT_TEMPLATES) {
return $resource(
API_ENDPOINT_TEMPLATES + '/:id',
API_ENDPOINT_TEMPLATES + '/:action',
{},
{
query: { method: 'GET' },
file: { method: 'POST', params: { action: 'file' } },
}
);
},

View file

@ -0,0 +1,59 @@
import angular from 'angular';
angular.module('portainer.app').factory('CustomTemplateService', CustomTemplateServiceFactory);
/* @ngInject */
function CustomTemplateServiceFactory(CustomTemplates, FileUploadService) {
var service = {};
service.customTemplate = function customTemplate(id) {
return CustomTemplates.get({ id }).$promise;
};
service.customTemplates = function customTemplates(type) {
return CustomTemplates.query({ type }).$promise;
};
service.remove = function remove(id) {
return CustomTemplates.remove({ id }).$promise;
};
service.customTemplateFile = async function customTemplateFile(id) {
try {
const { FileContent } = await CustomTemplates.file({ id }).$promise;
return FileContent;
} catch (err) {
throw { msg: 'Unable to retrieve customTemplate content', err };
}
};
service.updateCustomTemplate = async function updateCustomTemplate(id, customTemplate) {
return CustomTemplates.update({ id }, customTemplate).$promise;
};
service.createCustomTemplateFromFileContent = async function createCustomTemplateFromFileContent(payload) {
try {
return await CustomTemplates.create({ method: 'string' }, payload).$promise;
} catch (err) {
throw { msg: 'Unable to create the customTemplate', err };
}
};
service.createCustomTemplateFromFileUpload = async function createCustomTemplateFromFileUpload(payload) {
try {
return await FileUploadService.createCustomTemplate(payload);
} catch (err) {
throw { msg: 'Unable to create the customTemplate', err };
}
};
service.createCustomTemplateFromGitRepository = async function createCustomTemplateFromGitRepository(payload) {
try {
return await CustomTemplates.create({ method: 'repository' }, payload).$promise;
} catch (err) {
throw { msg: 'Unable to create the customTemplate', err };
}
};
return service;
}

View file

@ -43,6 +43,11 @@ angular.module('portainer.app').factory('TemplateService', [
return deferred.promise;
};
service.templateFile = templateFile;
function templateFile(repositoryUrl, composeFilePathInRepository) {
return Templates.file({ repositoryUrl, composeFilePathInRepository }).$promise;
}
service.createTemplateConfiguration = function (template, containerName, network) {
var imageConfiguration = ImageHelper.createImageConfigForContainer(template.RegistryModel);
var containerConfiguration = createContainerConfiguration(template, containerName, network);

View file

@ -97,6 +97,14 @@ angular.module('portainer.app').factory('FileUploadService', [
});
};
service.createCustomTemplate = function createCustomTemplate(data) {
return Upload.upload({
url: 'api/custom_templates?method=file',
data,
ignoreLoadingBar: true,
});
};
service.configureRegistry = function (registryId, registryManagementConfigurationModel) {
return Upload.upload({
url: 'api/registries/' + registryId + '/configure',

View file

@ -143,6 +143,12 @@ angular.module('portainer.app').factory('ModalService', [
});
};
service.confirmDeletionAsync = function confirmDeletionAsync(message) {
return new Promise((resolve) => {
service.confirmDeletion(message, (confirmed) => resolve(confirmed));
});
};
service.confirmContainerDeletion = function (title, callback) {
title = $sanitize(title);
prompt({

View file

@ -0,0 +1,217 @@
<rd-header>
<rd-header-title title-text="Create Custom template"></rd-header-title>
<rd-header-content> <a ui-sref="portainer.templates.custom">Custom Templates</a> &gt; Create Custom template </rd-header-content>
</rd-header>
<div class="row" ng-if="!$ctrl.state.loading">
<div class="col-sm-12">
<rd-widget>
<rd-widget-body>
<form class="form-horizontal" name="customTemplateForm">
<custom-template-common-fields form-values="$ctrl.formValues"></custom-template-common-fields>
<!-- build-method -->
<div ng-if="!$ctrl.state.fromStack">
<div class="col-sm-12 form-section-title">
Build method
</div>
<div class="form-group"></div>
<div class="form-group" style="margin-bottom: 0;">
<div class="boxselector_wrapper">
<div>
<input type="radio" id="method_editor" ng-model="$ctrl.state.Method" value="editor" ng-change="$ctrl.onChangeMethod()" />
<label for="method_editor">
<div class="boxselector_header">
<i class="fa fa-edit" aria-hidden="true" style="margin-right: 2px;"></i>
Web editor
</div>
<p>Use our Web editor</p>
</label>
</div>
<div>
<input type="radio" id="method_upload" ng-model="$ctrl.state.Method" value="upload" ng-change="$ctrl.onChangeMethod()" />
<label for="method_upload">
<div class="boxselector_header">
<i class="fa fa-upload" aria-hidden="true" style="margin-right: 2px;"></i>
Upload
</div>
<p>Upload from your computer</p>
</label>
</div>
<div>
<input type="radio" id="method_repository" ng-model="$ctrl.state.Method" value="repository" ng-change="$ctrl.onChangeMethod()" />
<label for="method_repository">
<div class="boxselector_header">
<i class="fab fa-git" aria-hidden="true" style="margin-right: 2px;"></i>
Repository
</div>
<p>Use a git repository</p>
</label>
</div>
</div>
</div>
</div>
<!-- !build-method -->
<!-- web-editor -->
<div ng-show="$ctrl.state.Method === 'editor'">
<div class="col-sm-12 form-section-title">
Web editor
</div>
<div class="form-group">
<span class="col-sm-12 text-muted small">
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>
.
</span>
</div>
<div class="form-group">
<div class="col-sm-12">
<code-editor
identifier="custom-template-creation-editor"
placeholder="# Define or paste the content of your docker-compose file here"
yml="true"
value="$ctrl.formValues.FileContent"
on-change="($ctrl.editorUpdate)"
></code-editor>
</div>
</div>
</div>
<!-- !web-editor -->
<!-- upload -->
<div ng-show="$ctrl.state.Method === 'upload'">
<div class="col-sm-12 form-section-title">
Upload
</div>
<div class="form-group">
<span class="col-sm-12 text-muted small">
You can upload a Compose file from your computer.
</span>
</div>
<div class="form-group">
<div class="col-sm-12">
<button class="btn btn-sm btn-primary" ngf-select ng-model="$ctrl.formValues.File">
Select file
</button>
<span style="margin-left: 5px;">
{{ $ctrl.formValues.File.name }}
<i class="fa fa-times red-icon" ng-if="!$ctrl.formValues.File" aria-hidden="true"></i>
</span>
</div>
</div>
</div>
<!-- !upload -->
<!-- repository -->
<div ng-show="$ctrl.state.Method === 'repository'">
<div class="col-sm-12 form-section-title">
Git repository
</div>
<div class="form-group">
<span class="col-sm-12 text-muted small">
You can use the URL of a git repository.
</span>
</div>
<div class="form-group">
<label for="template_repository_url" class="col-sm-2 control-label text-left">Repository URL</label>
<div class="col-sm-10">
<input
type="text"
class="form-control"
ng-model="$ctrl.formValues.RepositoryURL"
id="template_repository_url"
placeholder="https://github.com/portainer/portainer-compose"
/>
</div>
</div>
<div class="form-group">
<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>. If not specified, will use the default <code>HEAD</code> reference normally the
<code>master</code> branch.
</span>
</div>
<div class="form-group">
<label for="template_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="$ctrl.formValues.RepositoryReferenceName"
id="template_repository_reference_name"
placeholder="refs/heads/master"
/>
</div>
</div>
<div class="form-group">
<span class="col-sm-12 text-muted small">
Indicate the path to the Compose file from the root of your repository.
</span>
</div>
<div class="form-group">
<label for="template_repository_path" class="col-sm-2 control-label text-left">Compose path</label>
<div class="col-sm-10">
<input type="text" class="form-control" ng-model="$ctrl.formValues.ComposeFilePathInRepository" id="template_repository_path" placeholder="docker-compose.yml" />
</div>
</div>
<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="$ctrl.formValues.RepositoryAuthentication" /><i></i> </label>
</div>
</div>
<div class="form-group" ng-if="$ctrl.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 creating your template. In this case, you will need to provide a personal-access token instead of your password.
</span>
</div>
<div class="form-group" ng-if="$ctrl.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="$ctrl.formValues.RepositoryUsername" name="repository_username" placeholder="myGitUser" />
</div>
<label for="repository_password" class="col-sm-1 control-label text-left">
Password
</label>
<div class="col-sm-11 col-md-5">
<input type="password" class="form-control" ng-model="$ctrl.formValues.RepositoryPassword" name="repository_password" placeholder="myPassword" />
</div>
</div>
</div>
<!-- !repository -->
<por-access-control-form form-data="$ctrl.formValues.AccessControlData"></por-access-control-form>
<!-- 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 || customTemplateForm.$invalid
|| ($ctrl.state.Method === 'editor' && !$ctrl.formValues.FileContent)
|| ($ctrl.state.Method === 'upload' && !$ctrl.formValues.File)
|| ($ctrl.state.Method === 'repository' && ((!$ctrl.formValues.RepositoryURL || !$ctrl.formValues.ComposeFilePathInRepository) || ($ctrl.formValues.RepositoryAuthentication && (!$ctrl.formValues.RepositoryUsername || !$ctrl.formValues.RepositoryPassword))))
|| !$ctrl.formValues.Title"
ng-click="$ctrl.createCustomTemplate()"
button-spinner="$ctrl.state.actionInProgress"
>
<span ng-hide="$ctrl.state.actionInProgress">Create custom template</span>
<span ng-show="$ctrl.state.actionInProgress">Creation 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

@ -0,0 +1,152 @@
import { AccessControlFormData } from 'Portainer/components/accessControlForm/porAccessControlFormModel';
class CreateCustomTemplateViewController {
/* @ngInject */
constructor($async, $state, Authentication, CustomTemplateService, FormValidator, Notifications, ResourceControlService, StackService, StateManager) {
Object.assign(this, { $async, $state, Authentication, CustomTemplateService, FormValidator, Notifications, ResourceControlService, StackService, StateManager });
this.formValues = {
Title: '',
FileContent: '',
File: null,
RepositoryURL: '',
RepositoryReferenceName: '',
RepositoryAuthentication: false,
RepositoryUsername: '',
RepositoryPassword: '',
ComposeFilePathInRepository: 'docker-compose.yml',
Description: '',
Note: '',
Platform: 1,
Type: 1,
AccessControlData: new AccessControlFormData(),
};
this.state = {
Method: 'editor',
formValidationError: '',
actionInProgress: false,
fromStack: false,
loading: true,
};
this.createCustomTemplate = this.createCustomTemplate.bind(this);
this.createCustomTemplateAsync = this.createCustomTemplateAsync.bind(this);
this.validateForm = this.validateForm.bind(this);
this.createCustomTemplateByMethod = this.createCustomTemplateByMethod.bind(this);
this.createCustomTemplateFromFileContent = this.createCustomTemplateFromFileContent.bind(this);
this.createCustomTemplateFromFileUpload = this.createCustomTemplateFromFileUpload.bind(this);
this.createCustomTemplateFromGitRepository = this.createCustomTemplateFromGitRepository.bind(this);
this.editorUpdate = this.editorUpdate.bind(this);
this.onChangeMethod = this.onChangeMethod.bind(this);
}
createCustomTemplate() {
return this.$async(this.createCustomTemplateAsync);
}
onChangeMethod() {
this.formValues.FileContent = '';
this.selectedTemplate = null;
}
async createCustomTemplateAsync() {
let method = this.state.Method;
if (method === 'template') {
method = 'editor';
}
if (!this.validateForm(method)) {
return;
}
this.state.actionInProgress = true;
try {
const { ResourceControl } = await this.createCustomTemplateByMethod(method);
const accessControlData = this.formValues.AccessControlData;
const userDetails = this.Authentication.getUserDetails();
const userId = userDetails.ID;
await this.ResourceControlService.applyResourceControl(userId, accessControlData, ResourceControl);
this.Notifications.success('Custom template successfully created');
this.$state.go('portainer.templates.custom');
} catch (err) {
this.Notifications.error('Deployment error', err, 'Unable to create custom template');
} finally {
this.state.actionInProgress = false;
}
}
validateForm(method) {
this.state.formValidationError = '';
if (method === 'editor' && this.formValues.FileContent === '') {
this.state.formValidationError = 'Template file content must not be empty';
return false;
}
const isAdmin = this.Authentication.isAdmin();
const accessControlData = this.formValues.AccessControlData;
const error = this.FormValidator.validateAccessControl(accessControlData, isAdmin);
if (error) {
this.state.formValidationError = error;
return false;
}
return true;
}
createCustomTemplateByMethod(method) {
switch (method) {
case 'editor':
return this.createCustomTemplateFromFileContent();
case 'upload':
return this.createCustomTemplateFromFileUpload();
case 'repository':
return this.createCustomTemplateFromGitRepository();
}
}
createCustomTemplateFromFileContent() {
return this.CustomTemplateService.createCustomTemplateFromFileContent(this.formValues);
}
createCustomTemplateFromFileUpload() {
return this.CustomTemplateService.createCustomTemplateFromFileUpload(this.formValues);
}
createCustomTemplateFromGitRepository() {
return this.CustomTemplateService.createCustomTemplateFromGitRepository(this.formValues);
}
editorUpdate(cm) {
this.formValues.FileContent = cm.getValue();
}
async $onInit() {
const applicationState = this.StateManager.getState();
this.state.endpointMode = applicationState.endpoint.mode;
let stackType = 0;
if (this.state.endpointMode.provider === 'DOCKER_STANDALONE') {
stackType = 2;
} else if (this.state.endpointMode.provider === 'DOCKER_SWARM_MODE') {
stackType = 1;
}
this.formValues.Type = stackType;
const { fileContent, type } = this.$state.params;
this.formValues.FileContent = fileContent;
if (type) {
this.formValues.Type = +type;
}
this.state.loading = false;
}
}
export default CreateCustomTemplateViewController;

View file

@ -0,0 +1,6 @@
import CreateCustomTemplateViewController from './createCustomTemplateViewController.js';
angular.module('portainer.app').component('createCustomTemplateView', {
templateUrl: './createCustomTemplateView.html',
controller: CreateCustomTemplateViewController,
});

View file

@ -0,0 +1,72 @@
<rd-header id="view-top">
<rd-header-title title-text="Custom Templates">
<a data-toggle="tooltip" title="Refresh" ui-sref="portainer.templates.custom" ui-sref-opts="{reload: true}">
<i class="fa fa-sync" aria-hidden="true"></i>
</a>
</rd-header-title>
<rd-header-content>Custom Templates</rd-header-content>
</rd-header>
<div class="row">
<stack-from-template-form
ng-if="$ctrl.state.selectedTemplate"
template="$ctrl.state.selectedTemplate"
form-values="$ctrl.formValues"
state="$ctrl.state"
create-template="$ctrl.createStack"
unselect-template="$ctrl.unselectTemplate"
>
<advanced-form>
<div class="form-group">
<div class="col-sm-12">
<a class="small interactive" ng-show="!$ctrl.state.showAdvancedOptions" ng-click="$ctrl.state.showAdvancedOptions = true;">
<i class="fa fa-plus space-right" aria-hidden="true"></i> Customize stack
</a>
<a class="small interactive" ng-show="$ctrl.state.showAdvancedOptions" ng-click="$ctrl.state.showAdvancedOptions = false;">
<i class="fa fa-minus space-right" aria-hidden="true"></i> Hide custom stack
</a>
</div>
</div>
<div ng-if="$ctrl.state.showAdvancedOptions">
<!-- web-editor -->
<div class="col-sm-12 form-section-title">
Web editor
</div>
<div class="form-group">
<span class="col-sm-12 text-muted small">
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>
.
</span>
</div>
<div class="form-group">
<div class="col-sm-12">
<code-editor
identifier="custom-template-creation-editor"
placeholder="# Define or paste the content of your docker-compose file here"
yml="true"
value="$ctrl.formValues.fileContent"
on-change="($ctrl.editorUpdate)"
></code-editor>
</div>
</div>
<!-- !web-editor -->
</div>
</advanced-form>
</stack-from-template-form>
</div>
<div class="row">
<div class="col-sm-12">
<custom-templates-list
ng-if="$ctrl.templates"
title-text="Templates"
title-icon="fa-rocket"
templates="$ctrl.templates"
table-key="templates"
is-edit-allowed="$ctrl.isEditAllowed"
on-select-click="($ctrl.selectTemplate)"
on-delete-click="($ctrl.confirmDelete)"
></custom-templates-list>
</div>
</div>

View file

@ -0,0 +1,243 @@
import _ from 'lodash-es';
import { AccessControlFormData } from 'Portainer/components/accessControlForm/porAccessControlFormModel';
class CustomTemplatesViewController {
/* @ngInject */
constructor(
$anchorScroll,
$async,
$rootScope,
$state,
Authentication,
CustomTemplateService,
EndpointProvider,
FormValidator,
ModalService,
NetworkService,
Notifications,
ResourceControlService,
StackService,
StateManager
) {
this.$anchorScroll = $anchorScroll;
this.$async = $async;
this.$rootScope = $rootScope;
this.$state = $state;
this.Authentication = Authentication;
this.CustomTemplateService = CustomTemplateService;
this.EndpointProvider = EndpointProvider;
this.FormValidator = FormValidator;
this.ModalService = ModalService;
this.NetworkService = NetworkService;
this.Notifications = Notifications;
this.ResourceControlService = ResourceControlService;
this.StateManager = StateManager;
this.StackService = StackService;
this.state = {
selectedTemplate: null,
showAdvancedOptions: false,
formValidationError: '',
actionInProgress: false,
isEditorVisible: false,
};
this.currentUser = {
isAdmin: false,
id: null,
};
this.formValues = {
network: '',
name: '',
fileContent: '',
AccessControlData: new AccessControlFormData(),
};
this.getTemplates = this.getTemplates.bind(this);
this.getTemplatesAsync = this.getTemplatesAsync.bind(this);
this.removeTemplates = this.removeTemplates.bind(this);
this.removeTemplatesAsync = this.removeTemplatesAsync.bind(this);
this.validateForm = this.validateForm.bind(this);
this.createStack = this.createStack.bind(this);
this.createStackAsync = this.createStackAsync.bind(this);
this.selectTemplate = this.selectTemplate.bind(this);
this.selectTemplateAsync = this.selectTemplateAsync.bind(this);
this.unselectTemplate = this.unselectTemplate.bind(this);
this.unselectTemplateAsync = this.unselectTemplateAsync.bind(this);
this.getNetworks = this.getNetworks.bind(this);
this.getNetworksAsync = this.getNetworksAsync.bind(this);
this.confirmDelete = this.confirmDelete.bind(this);
this.confirmDeleteAsync = this.confirmDeleteAsync.bind(this);
this.editorUpdate = this.editorUpdate.bind(this);
this.isEditAllowed = this.isEditAllowed.bind(this);
}
isEditAllowed(template) {
return this.currentUser.isAdmin || this.currentUser.id === template.CreatedByUserId;
}
getTemplates(endpointMode) {
return this.$async(this.getTemplatesAsync, endpointMode);
}
async getTemplatesAsync({ provider, role }) {
try {
let stackType = 2;
if (provider === 'DOCKER_SWARM_MODE' && role === 'MANAGER') {
stackType = 1;
}
this.templates = await this.CustomTemplateService.customTemplates(stackType);
} catch (err) {
this.Notifications.error('Failed loading templates', err, 'Unable to load custom templates');
}
}
removeTemplates(templates) {
return this.$async(this.removeTemplatesAsync, templates);
}
async removeTemplatesAsync(templates) {
for (let template of templates) {
try {
await this.CustomTemplateService.remove(template.id);
this.Notifications.success('Removed template successfully');
_.remove(this.templates, template);
} catch (err) {
this.Notifications.error('Failed removing template', err, 'Unable to remove custom template');
}
}
}
validateForm(accessControlData, isAdmin) {
this.state.formValidationError = '';
const error = this.FormValidator.validateAccessControl(accessControlData, isAdmin);
if (error) {
this.state.formValidationError = error;
return false;
}
return true;
}
createStack() {
return this.$async(this.createStackAsync);
}
async createStackAsync() {
const userId = this.currentUser.id;
const accessControlData = this.formValues.AccessControlData;
if (!this.validateForm(accessControlData, this.currentUser.isAdmin)) {
return;
}
const stackName = this.formValues.name;
const endpointId = this.EndpointProvider.endpointID();
this.state.actionInProgress = true;
try {
const file = this.formValues.fileContent;
const createAction = this.state.selectedTemplate.Type === 1 ? this.StackService.createSwarmStackFromFileContent : this.StackService.createComposeStackFromFileContent;
const { ResourceControl: resourceControl } = await createAction(stackName, file, [], endpointId);
await this.ResourceControlService.applyResourceControl(userId, accessControlData, resourceControl);
this.Notifications.success('Stack successfully deployed');
this.$state.go('portainer.stacks');
} catch (err) {
this.Notifications.error('Deployment error', err, 'Failed to deploy stack');
} finally {
this.state.actionInProgress = false;
}
}
unselectTemplate(template) {
// wrapping unselect with async to make a digest cycle run between unselect to select
return this.$async(this.unselectTemplateAsync, template);
}
async unselectTemplateAsync(template) {
template.Selected = false;
this.state.selectedTemplate = null;
this.formValues = {
network: '',
name: '',
fileContent: '',
AccessControlData: new AccessControlFormData(),
};
}
selectTemplate(template) {
return this.$async(this.selectTemplateAsync, template);
}
async selectTemplateAsync(template) {
if (this.state.selectedTemplate) {
await this.unselectTemplate(this.state.selectedTemplate);
}
template.Selected = true;
this.formValues.network = _.find(this.availableNetworks, function (o) {
return o.Name === 'bridge';
});
this.formValues.name = template.Title ? template.Title : '';
this.state.selectedTemplate = template;
this.$anchorScroll('view-top');
const file = await this.CustomTemplateService.customTemplateFile(template.Id);
this.formValues.fileContent = file;
}
getNetworks(provider, apiVersion) {
return this.$async(this.getNetworksAsync, provider, apiVersion);
}
async getNetworksAsync(provider, apiVersion) {
try {
const networks = await this.NetworkService.networks(
provider === 'DOCKER_STANDALONE' || provider === 'DOCKER_SWARM_MODE',
false,
provider === 'DOCKER_SWARM_MODE' && apiVersion >= 1.25
);
this.availableNetworks = networks;
} catch (err) {
this.Notifications.error('Failure', err, 'Failed to load networks.');
}
}
confirmDelete(templateId) {
return this.$async(this.confirmDeleteAsync, templateId);
}
async confirmDeleteAsync(templateId) {
const confirmed = await this.ModalService.confirmDeletionAsync('Are you sure that you want to delete this template?');
if (!confirmed) {
return;
}
try {
await this.CustomTemplateService.remove(templateId);
_.remove(this.templates, { Id: templateId });
} catch (err) {
this.Notifications.error('Failure', err, 'Failed to delete template');
}
}
editorUpdate(cm) {
this.formValues.fileContent = cm.getValue();
}
$onInit() {
const applicationState = this.StateManager.getState();
const {
endpoint: { mode: endpointMode },
apiVersion,
} = applicationState;
this.getTemplates(endpointMode);
this.getNetworks(endpointMode.provider, apiVersion);
this.currentUser.isAdmin = this.Authentication.isAdmin();
const user = this.Authentication.getUserDetails();
this.currentUser.id = user.ID;
}
}
export default CustomTemplatesViewController;

View file

@ -0,0 +1,6 @@
import CustomTemplatesViewController from './customTemplatesViewController.js';
angular.module('portainer.app').component('customTemplatesView', {
templateUrl: './customTemplatesView.html',
controller: CustomTemplatesViewController,
});

View file

@ -0,0 +1,71 @@
<rd-header>
<rd-header-title title-text="Edit Custom Template">
<a data-toggle="tooltip" title="Refresh" ui-sref="portainer.templates.custom.edit({id:$ctrl.formValues.Id})" ui-sref-opts="{reload: true}">
<i class="fa fa-sync" aria-hidden="true"></i>
</a>
</rd-header-title>
<rd-header-content> <a ui-sref="portainer.templates.custom">Custom templates</a> &gt; {{ $ctrl.formValues.Title }} </rd-header-content>
</rd-header>
<div class="row" ng-if="$ctrl.formValues">
<div class="col-sm-12">
<rd-widget>
<rd-widget-body>
<form class="form-horizontal" name="customTemplateForm">
<custom-template-common-fields form-values="$ctrl.formValues"></custom-template-common-fields>
<!-- web-editor -->
<div class="col-sm-12 form-section-title">
Web editor
</div>
<div class="form-group">
<span class="col-sm-12 text-muted small">
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>
.
</span>
</div>
<div class="form-group">
<div class="col-sm-12">
<code-editor
identifier="custom-template-creation-editor"
placeholder="# Define or paste the content of your docker-compose file here"
yml="true"
value="$ctrl.formValues.FileContent"
on-change="($ctrl.editorUpdate)"
></code-editor>
</div>
</div>
<!-- !web-editor -->
<por-access-control-form form-data="$ctrl.formValues.AccessControlData" resource-control="$ctrl.formValues.ResourceControl"></por-access-control-form>
<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.actionInProgress || customTemplateForm.$invalid
|| !$ctrl.formValues.Title
|| !$ctrl.formValues.FileContent"
ng-click="$ctrl.submitAction()"
button-spinner="$ctrl.actionInProgress"
>
<span ng-hide="$ctrl.actionInProgress">Update the template</span>
<span ng-show="$ctrl.actionInProgress">Update in progress...</span>
</button>
<span class="text-danger" ng-if="$ctrl.state.formValidationError" style="margin-left: 5px;">
{{ $ctrl.state.formValidationError }}
</span>
</div>
</div>
</form>
</rd-widget-body>
</rd-widget>
</div>
</div>

View file

@ -0,0 +1,93 @@
import { AccessControlFormData } from 'Portainer/components/accessControlForm/porAccessControlFormModel';
import { ResourceControlViewModel } from 'Portainer/models/resourceControl/resourceControl';
class EditCustomTemplateViewController {
/* @ngInject */
constructor($async, $state, Authentication, CustomTemplateService, FormValidator, Notifications, ResourceControlService) {
Object.assign(this, { $async, $state, Authentication, CustomTemplateService, FormValidator, Notifications, ResourceControlService });
this.formValues = null;
this.state = {
formValidationError: '',
};
this.getTemplate = this.getTemplate.bind(this);
this.getTemplateAsync = this.getTemplateAsync.bind(this);
this.submitAction = this.submitAction.bind(this);
this.submitActionAsync = this.submitActionAsync.bind(this);
this.editorUpdate = this.editorUpdate.bind(this);
}
getTemplate() {
return this.$async(this.getTemplateAsync);
}
async getTemplateAsync() {
try {
const [template, file] = await Promise.all([
this.CustomTemplateService.customTemplate(this.$state.params.id),
this.CustomTemplateService.customTemplateFile(this.$state.params.id),
]);
template.FileContent = file;
this.formValues = template;
this.formValues.ResourceControl = new ResourceControlViewModel(template.ResourceControl);
this.formValues.AccessControlData = new AccessControlFormData();
} catch (err) {
this.Notifications.error('Failure', err, 'Unable to retrieve custom template data');
}
}
validateForm() {
this.state.formValidationError = '';
if (!this.formValues.FileContent) {
this.state.formValidationError = 'Template file content must not be empty';
return false;
}
const isAdmin = this.Authentication.isAdmin();
const accessControlData = this.formValues.AccessControlData;
const error = this.FormValidator.validateAccessControl(accessControlData, isAdmin);
if (error) {
this.state.formValidationError = error;
return false;
}
return true;
}
submitAction() {
return this.$async(this.submitActionAsync);
}
async submitActionAsync() {
if (!this.validateForm()) {
return;
}
this.actionInProgress = true;
try {
await this.CustomTemplateService.updateCustomTemplate(this.formValues.Id, this.formValues);
const userDetails = this.Authentication.getUserDetails();
const userId = userDetails.ID;
await this.ResourceControlService.applyResourceControl(userId, this.formValues.AccessControlData, this.formValues.ResourceControl);
this.Notifications.success('Custom template successfully updated');
this.$state.go('portainer.templates.custom');
} catch (err) {
this.Notifications.error('Failure', err, 'Unable to update custom template');
} finally {
this.actionInProgress = false;
}
}
editorUpdate(cm) {
this.formValues.fileContent = cm.getValue();
}
$onInit() {
this.getTemplate();
}
}
export default EditCustomTemplateViewController;

View file

@ -0,0 +1,6 @@
import EditCustomTemplateViewController from './editCustomTemplateViewController.js';
angular.module('portainer.app').component('editCustomTemplateView', {
templateUrl: './editCustomTemplateView.html',
controller: EditCustomTemplateViewController,
});

View file

@ -18,6 +18,8 @@
<azure-sidebar-content ng-if="applicationState.endpoint.mode && applicationState.endpoint.mode.provider === 'AZURE'"> </azure-sidebar-content>
<docker-sidebar-content
ng-if="applicationState.endpoint.mode && applicationState.endpoint.mode.provider !== 'AZURE' && applicationState.endpoint.mode.provider !== 'KUBERNETES'"
current-route-name="$state.current.name"
toggle="toggle"
endpoint-api-version="applicationState.endpoint.apiVersion"
swarm-management="applicationState.endpoint.mode.provider === 'DOCKER_SWARM_MODE' && applicationState.endpoint.mode.role === 'MANAGER'"
standalone-management="applicationState.endpoint.mode.provider === 'DOCKER_STANDALONE'"

View file

@ -1,16 +1,22 @@
import angular from 'angular';
import _ from 'lodash-es';
import { AccessControlFormData } from '../../../components/accessControlForm/porAccessControlFormModel';
angular.module('portainer.app').controller('CreateStackController', [
'$scope',
'$state',
'StackService',
'Authentication',
'Notifications',
'FormValidator',
'ResourceControlService',
'FormHelper',
'EndpointProvider',
function ($scope, $state, StackService, Authentication, Notifications, FormValidator, ResourceControlService, FormHelper, EndpointProvider) {
angular
.module('portainer.app')
.controller('CreateStackController', function (
$scope,
$state,
StackService,
Authentication,
Notifications,
FormValidator,
ResourceControlService,
FormHelper,
EndpointProvider,
CustomTemplateService
) {
$scope.formValues = {
Name: '',
StackFileContent: '',
@ -56,13 +62,17 @@ angular.module('portainer.app').controller('CreateStackController', [
var env = FormHelper.removeInvalidEnvVars($scope.formValues.Env);
var endpointId = EndpointProvider.endpointID();
if (method === 'editor') {
if (method === 'template' || method === 'editor') {
var stackFileContent = $scope.formValues.StackFileContent;
return StackService.createSwarmStackFromFileContent(name, stackFileContent, env, endpointId);
} else if (method === 'upload') {
}
if (method === 'upload') {
var stackFile = $scope.formValues.StackFile;
return StackService.createSwarmStackFromFileUpload(name, stackFile, env, endpointId);
} else if (method === 'repository') {
}
if (method === 'repository') {
var repositoryOptions = {
RepositoryURL: $scope.formValues.RepositoryURL,
RepositoryReferenceName: $scope.formValues.RepositoryReferenceName,
@ -79,7 +89,7 @@ angular.module('portainer.app').controller('CreateStackController', [
var env = FormHelper.removeInvalidEnvVars($scope.formValues.Env);
var endpointId = EndpointProvider.endpointID();
if (method === 'editor') {
if (method === 'editor' || method === 'template') {
var stackFileContent = $scope.formValues.StackFileContent;
return StackService.createComposeStackFromFileContent(name, stackFileContent, env, endpointId);
} else if (method === 'upload') {
@ -146,14 +156,29 @@ angular.module('portainer.app').controller('CreateStackController', [
$scope.formValues.StackFileContent = cm.getValue();
};
function initView() {
$scope.onChangeTemplate = async function onChangeTemplate(template) {
try {
$scope.selectedTemplate = template;
$scope.formValues.StackFileContent = await CustomTemplateService.customTemplateFile(template.Id);
} catch (err) {
Notifications.error('Failure', err, 'Unable to retrieve Custom Template file');
}
};
async function initView() {
var endpointMode = $scope.applicationState.endpoint.mode;
$scope.state.StackType = 2;
if (endpointMode.provider === 'DOCKER_SWARM_MODE' && endpointMode.role === 'MANAGER') {
$scope.state.StackType = 1;
}
try {
const templates = await CustomTemplateService.customTemplates($scope.state.StackType);
$scope.templates = _.map(templates, (template) => ({ ...template, label: `${template.Title} - ${template.Description}` }));
} catch (err) {
Notifications.error('Failure', err, 'Unable to retrieve Custom Templates');
}
}
initView();
},
]);
});

View file

@ -63,6 +63,16 @@
<p>Use a git repository</p>
</label>
</div>
<div>
<input type="radio" id="method_template" ng-model="state.Method" value="template" />
<label for="method_template">
<div class="boxselector_header">
<i class="fa fa-rocket" aria-hidden="true" style="margin-right: 2px;"></i>
Custom template
</div>
<p>Use a custom template</p>
</label>
</div>
</div>
</div>
<!-- !build-method -->
@ -181,6 +191,59 @@
</div>
</div>
</div>
<!-- !repository -->
<!-- custom-template -->
<div ng-show="state.Method === 'template'">
<div class="form-group">
<label for="stack_template" class="col-sm-1 control-label text-left">
Template
</label>
<div class="col-sm-11">
<select
ng-if="templates.length"
class="form-control"
ng-model="selectedTemplate"
ng-options="template as template.label for template in templates"
ng-change="onChangeTemplate(selectedTemplate)"
>
<option value="" label="Select a Custom template" disabled selected="selected"> </option>
</select>
<span ng-if="!templates.length">
No custom template are available. Head over the <a ui-sref="portainer.templates.custom.new">custom template view</a> to create one.
</span>
</div>
</div>
<!-- description -->
<div ng-if="selectedTemplate.note">
<div class="col-sm-12 form-section-title">
Information
</div>
<div class="form-group">
<div class="col-sm-12">
<div class="template-note" ng-bind-html="selectedTemplate.note"></div>
</div>
</div>
</div>
<!-- !description -->
<!-- editor -->
<div ng-if="selectedTemplate && formValues.StackFileContent">
<div class="col-sm-12 form-section-title">
Web editor
</div>
<div class="form-group">
<div class="col-sm-12">
<code-editor
identifier="template-content-editor"
placeholder="# Define or paste the content of your docker-compose file here"
yml="true"
value="formValues.StackFileContent"
on-change="(editorUpdate)"
></code-editor>
</div>
</div>
</div>
</div>
<!-- !custom-template -->
<!-- environment-variables -->
<div>
<div class="col-sm-12 form-section-title">
@ -213,7 +276,6 @@
</div>
</div>
<!-- !environment-variables -->
<!-- !repository -->
<por-access-control-form form-data="formValues.AccessControlData"></por-access-control-form>
<!-- actions -->
<div class="col-sm-12 form-section-title">

View file

@ -38,9 +38,19 @@
</div>
<div class="form-group">
{{ stackName }}
<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
<a
ng-if="!state.externalStack && stackFileContent"
class="btn btn-primary btn-xs"
ui-sref="portainer.templates.custom.new({fileContent: stackFileContent, type: stack.Type})"
>
Create template from stack
</a>
<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>
</div>
</div>
<!-- !stack-details -->

View file

@ -9,77 +9,15 @@
<div class="row">
<!-- stack-form -->
<div class="col-sm-12" ng-if="state.selectedTemplate && (state.selectedTemplate.Type === 2 || state.selectedTemplate.Type === 3)">
<rd-widget>
<rd-widget-custom-header icon="state.selectedTemplate.Logo" title-text="state.selectedTemplate.Title"></rd-widget-custom-header>
<rd-widget-body classes="padding">
<form class="form-horizontal">
<!-- description -->
<div ng-if="state.selectedTemplate.Note">
<div class="col-sm-12 form-section-title">
Information
</div>
<div class="form-group">
<div class="col-sm-12">
<div class="template-note" ng-if="state.selectedTemplate.Note" ng-bind-html="state.selectedTemplate.Note"></div>
</div>
</div>
</div>
<!-- !description -->
<div class="col-sm-12 form-section-title">
Configuration
</div>
<!-- name-input -->
<div class="form-group">
<label for="container_name" class="col-sm-2 control-label text-left">Name</label>
<div class="col-sm-10">
<input type="text" name="container_name" class="form-control" ng-model="formValues.name" placeholder="e.g. myStack" required />
</div>
</div>
<!-- !name-input -->
<!-- env -->
<div ng-repeat="var in state.selectedTemplate.Env" ng-if="!var.preset || var.select" class="form-group">
<label for="field_{{ $index }}" class="col-sm-2 control-label text-left">
{{ var.label }}
<portainer-tooltip ng-if="var.description" position="bottom" message="{{ var.description }}"></portainer-tooltip>
</label>
<div class="col-sm-10">
<input type="text" class="form-control" ng-if="!var.select" ng-model="var.value" id="field_{{ $index }}" />
<select class="form-control" ng-if="var.select" ng-model="var.value" id="field_{{ $index }}">
<option selected disabled hidden value="">Select value</option>
<option ng-repeat="choice in var.select" value="{{ choice.value }}">{{ choice.text }}</option>
</select>
</div>
</div>
<!-- !env -->
<!-- access-control -->
<por-access-control-form form-data="formValues.AccessControlData"></por-access-control-form>
<!-- !access-control -->
<!-- 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="state.actionInProgress || !formValues.name"
ng-click="createTemplate()"
button-spinner="state.actionInProgress"
>
<span ng-hide="state.actionInProgress">Deploy the stack</span>
<span ng-show="state.actionInProgress">Deployment in progress...</span>
</button>
<button type="button" class="btn btn-sm btn-default" ng-click="unselectTemplate(state.selectedTemplate)">Hide</button>
<span class="text-danger" ng-if="state.formValidationError" style="margin-left: 5px;">{{ state.formValidationError }}</span>
</div>
</div>
<!-- !actions -->
</form>
</rd-widget-body>
</rd-widget>
</div>
<stack-from-template-form
ng-if="state.selectedTemplate && (state.selectedTemplate.Type === 2 || state.selectedTemplate.Type === 3)"
template="state.selectedTemplate"
form-values="formValues"
state="state"
create-template="createTemplate"
unselect-template="unselectTemplate"
>
</stack-from-template-form>
<!-- !stack-form -->
<!-- container-form -->
<div class="col-sm-12" ng-if="state.selectedTemplate && state.selectedTemplate.Type === 1">