mirror of https://github.com/portainer/portainer
refactor(templates): migrate list view to react [EE-2296] (#10999)
parent
d38085a560
commit
6ff4fd3db2
|
@ -510,11 +510,10 @@ angular.module('portainer.docker', ['portainer.app', reactModule]).config([
|
||||||
|
|
||||||
var templates = {
|
var templates = {
|
||||||
name: 'docker.templates',
|
name: 'docker.templates',
|
||||||
url: '/templates',
|
url: '/templates?template',
|
||||||
views: {
|
views: {
|
||||||
'content@': {
|
'content@': {
|
||||||
templateUrl: '~Portainer/views/templates/templates.html',
|
component: 'appTemplatesView',
|
||||||
controller: 'TemplatesController',
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
data: {
|
data: {
|
||||||
|
|
|
@ -26,6 +26,7 @@ import { servicesModule } from './services';
|
||||||
import { networksModule } from './networks';
|
import { networksModule } from './networks';
|
||||||
import { swarmModule } from './swarm';
|
import { swarmModule } from './swarm';
|
||||||
import { volumesModule } from './volumes';
|
import { volumesModule } from './volumes';
|
||||||
|
import { templatesModule } from './templates';
|
||||||
|
|
||||||
const ngModule = angular
|
const ngModule = angular
|
||||||
.module('portainer.docker.react.components', [
|
.module('portainer.docker.react.components', [
|
||||||
|
@ -34,6 +35,7 @@ const ngModule = angular
|
||||||
networksModule,
|
networksModule,
|
||||||
swarmModule,
|
swarmModule,
|
||||||
volumesModule,
|
volumesModule,
|
||||||
|
templatesModule,
|
||||||
])
|
])
|
||||||
.component('dockerfileDetails', r2a(DockerfileDetails, ['image']))
|
.component('dockerfileDetails', r2a(DockerfileDetails, ['image']))
|
||||||
.component('dockerHealthStatus', r2a(HealthStatus, ['health']))
|
.component('dockerHealthStatus', r2a(HealthStatus, ['health']))
|
||||||
|
|
|
@ -0,0 +1,17 @@
|
||||||
|
import angular from 'angular';
|
||||||
|
|
||||||
|
import { r2a } from '@/react-tools/react2angular';
|
||||||
|
import { withCurrentUser } from '@/react-tools/withCurrentUser';
|
||||||
|
import { withUIRouter } from '@/react-tools/withUIRouter';
|
||||||
|
import { StackFromCustomTemplateFormWidget } from '@/react/docker/templates/StackFromCustomTemplateFormWidget';
|
||||||
|
|
||||||
|
export const templatesModule = angular
|
||||||
|
.module('portainer.docker.react.components.templates', [])
|
||||||
|
|
||||||
|
.component(
|
||||||
|
'stackFromCustomTemplateFormWidget',
|
||||||
|
r2a(withUIRouter(withCurrentUser(StackFromCustomTemplateFormWidget)), [
|
||||||
|
'template',
|
||||||
|
'unselect',
|
||||||
|
])
|
||||||
|
).name;
|
|
@ -112,24 +112,6 @@ function ContainerServiceFactory($q, Container, $timeout) {
|
||||||
return deferred.promise;
|
return deferred.promise;
|
||||||
};
|
};
|
||||||
|
|
||||||
service.createAndStartContainer = function (environmentId, configuration) {
|
|
||||||
var deferred = $q.defer();
|
|
||||||
var container;
|
|
||||||
service
|
|
||||||
.createContainer(environmentId, configuration)
|
|
||||||
.then(function success(data) {
|
|
||||||
container = data;
|
|
||||||
return service.startContainer(environmentId, container.Id);
|
|
||||||
})
|
|
||||||
.then(function success() {
|
|
||||||
deferred.resolve(container);
|
|
||||||
})
|
|
||||||
.catch(function error(err) {
|
|
||||||
deferred.reject(err);
|
|
||||||
});
|
|
||||||
return deferred.promise;
|
|
||||||
};
|
|
||||||
|
|
||||||
service.createExec = function (environmentId, execConfig) {
|
service.createExec = function (environmentId, execConfig) {
|
||||||
var deferred = $q.defer();
|
var deferred = $q.defer();
|
||||||
|
|
||||||
|
|
|
@ -92,14 +92,6 @@ angular.module('portainer.docker').factory('VolumeService', [
|
||||||
return $q.all(createVolumeQueries);
|
return $q.all(createVolumeQueries);
|
||||||
};
|
};
|
||||||
|
|
||||||
service.createXAutoGeneratedLocalVolumes = function (x) {
|
|
||||||
var createVolumeQueries = [];
|
|
||||||
for (var i = 0; i < x; i++) {
|
|
||||||
createVolumeQueries.push(service.createVolume({ Driver: 'local' }));
|
|
||||||
}
|
|
||||||
return $q.all(createVolumeQueries);
|
|
||||||
};
|
|
||||||
|
|
||||||
return service;
|
return service;
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
|
|
|
@ -154,7 +154,7 @@ angular
|
||||||
url: '/templates?template',
|
url: '/templates?template',
|
||||||
views: {
|
views: {
|
||||||
'content@': {
|
'content@': {
|
||||||
component: 'edgeAppTemplatesView',
|
component: 'appTemplatesView',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
data: {
|
data: {
|
||||||
|
|
|
@ -4,25 +4,10 @@ import { r2a } from '@/react-tools/react2angular';
|
||||||
import { withCurrentUser } from '@/react-tools/withCurrentUser';
|
import { withCurrentUser } from '@/react-tools/withCurrentUser';
|
||||||
import { withUIRouter } from '@/react-tools/withUIRouter';
|
import { withUIRouter } from '@/react-tools/withUIRouter';
|
||||||
import { ListView } from '@/react/edge/templates/custom-templates/ListView';
|
import { ListView } from '@/react/edge/templates/custom-templates/ListView';
|
||||||
import { AppTemplatesView } from '@/react/edge/templates/AppTemplatesView';
|
|
||||||
import { CreateView } from '@/react/portainer/templates/custom-templates/CreateView';
|
|
||||||
import { EditView } from '@/react/portainer/templates/custom-templates/EditView';
|
|
||||||
|
|
||||||
export const templatesModule = angular
|
export const templatesModule = angular
|
||||||
.module('portainer.app.react.components.templates', [])
|
.module('portainer.edge.react.views.templates', [])
|
||||||
.component(
|
|
||||||
'edgeAppTemplatesView',
|
|
||||||
r2a(withCurrentUser(withUIRouter(AppTemplatesView)), [])
|
|
||||||
)
|
|
||||||
.component(
|
.component(
|
||||||
'edgeCustomTemplatesView',
|
'edgeCustomTemplatesView',
|
||||||
r2a(withCurrentUser(withUIRouter(ListView)), [])
|
r2a(withCurrentUser(withUIRouter(ListView)), [])
|
||||||
)
|
|
||||||
.component(
|
|
||||||
'createCustomTemplatesView',
|
|
||||||
r2a(withCurrentUser(withUIRouter(CreateView)), [])
|
|
||||||
)
|
|
||||||
.component(
|
|
||||||
'editCustomTemplatesView',
|
|
||||||
r2a(withCurrentUser(withUIRouter(EditView)), [])
|
|
||||||
).name;
|
).name;
|
||||||
|
|
|
@ -17,7 +17,7 @@ import { getInitialTemplateValues } from '@/react/edge/edge-stacks/CreateView/Te
|
||||||
import { getAppTemplates } from '@/react/portainer/templates/app-templates/queries/useAppTemplates';
|
import { getAppTemplates } from '@/react/portainer/templates/app-templates/queries/useAppTemplates';
|
||||||
import { fetchFilePreview } from '@/react/portainer/templates/app-templates/queries/useFetchTemplateFile';
|
import { fetchFilePreview } from '@/react/portainer/templates/app-templates/queries/useFetchTemplateFile';
|
||||||
import { TemplateViewModel } from '@/react/portainer/templates/app-templates/view-model';
|
import { TemplateViewModel } from '@/react/portainer/templates/app-templates/view-model';
|
||||||
import { getDefaultValues as getAppVariablesDefaultValues } from '@/react/edge/edge-stacks/CreateView/TemplateFieldset/EnvVarsFieldset';
|
import { getDefaultValues as getAppVariablesDefaultValues } from '@/react/portainer/templates/app-templates/DeployFormWidget/EnvVarsFieldset';
|
||||||
|
|
||||||
export default class CreateEdgeStackViewController {
|
export default class CreateEdgeStackViewController {
|
||||||
/* @ngInject */
|
/* @ngInject */
|
||||||
|
|
|
@ -1,16 +0,0 @@
|
||||||
import angular from 'angular';
|
|
||||||
|
|
||||||
angular.module('portainer.app').component('stackFromTemplateForm', {
|
|
||||||
templateUrl: './stackFromTemplateForm.html',
|
|
||||||
bindings: {
|
|
||||||
template: '=',
|
|
||||||
formValues: '=',
|
|
||||||
state: '=',
|
|
||||||
createTemplate: '<',
|
|
||||||
unselectTemplate: '<',
|
|
||||||
nameRegex: '<',
|
|
||||||
},
|
|
||||||
transclude: {
|
|
||||||
advanced: '?advancedForm',
|
|
||||||
},
|
|
||||||
});
|
|
|
@ -1,95 +0,0 @@
|
||||||
<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" name="stackTemplateForm">
|
|
||||||
<!-- description -->
|
|
||||||
<div ng-if="$ctrl.template.Note">
|
|
||||||
<div class="form-section-title"> Information </div>
|
|
||||||
<div class="col-sm-12 form-group">
|
|
||||||
<div class="template-note" ng-bind-html="$ctrl.template.Note"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<!-- !description -->
|
|
||||||
<div class="form-section-title"> Configuration </div>
|
|
||||||
<!-- name-input -->
|
|
||||||
<div class="form-group">
|
|
||||||
<label for="template_name" class="col-sm-2 control-label text-left">Name</label>
|
|
||||||
<div class="col-sm-6">
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
name="template_name"
|
|
||||||
class="form-control"
|
|
||||||
ng-model="$ctrl.formValues.name"
|
|
||||||
ng-pattern="$ctrl.nameRegex"
|
|
||||||
placeholder="e.g. myStack"
|
|
||||||
required
|
|
||||||
data-cy="stack-name-input"
|
|
||||||
/>
|
|
||||||
<div class="form-group" ng-if="stackTemplateForm.template_name.$invalid">
|
|
||||||
<div class="col-sm-12 small text-warning">
|
|
||||||
<div ng-messages="stackTemplateForm.template_name.$error">
|
|
||||||
<p ng-message="pattern" class="vertical-center">
|
|
||||||
<pr-icon icon="'alert-triangle'" mode="'warning'"></pr-icon>
|
|
||||||
<span>This field must consist of lower case alphanumeric characters, '_' or '-' (e.g. 'my-name', or 'abc-123').</span>
|
|
||||||
</p>
|
|
||||||
<p ng-message="required" class="vertical-center"> <pr-icon icon="'alert-triangle'" mode="'warning'"></pr-icon>This field is required. </p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</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" message="var.description"></portainer-tooltip>
|
|
||||||
</label>
|
|
||||||
<div class="col-sm-6">
|
|
||||||
<input type="text" class="form-control" ng-if="!var.select" ng-model="var.value" id="field_{{ $index }}" data-cy="stackFromTemplateForm-input" />
|
|
||||||
<select class="form-control" ng-if="var.select" ng-model="var.value" id="field_{{ $index }}" data-cy="stackFromTemplateForm-select">
|
|
||||||
<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="form-section-title"> Actions </div>
|
|
||||||
<div class="form-group">
|
|
||||||
<div class="col-sm-12">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="btn btn-primary"
|
|
||||||
ng-disabled="$ctrl.state.actionInProgress || !$ctrl.formValues.name || !$ctrl.state.deployable || stackTemplateForm.$invalid"
|
|
||||||
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-default" ng-click="$ctrl.unselectTemplate($ctrl.template)">Hide</button>
|
|
||||||
<div class="form-group" ng-if="$ctrl.state.formValidationError">
|
|
||||||
<div class="col-sm-12 small text-danger" ng-if="$ctrl.state.formValidationError">
|
|
||||||
<p class="vertical-center"> <pr-icon icon="'alert-triangle'" mode="'warning'"></pr-icon>{{ $ctrl.state.formValidationError }} </p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="form-group" ng-if="!$ctrl.state.deployable">
|
|
||||||
<div class="col-sm-12 small text-danger" ng-if="!$ctrl.state.deployable">
|
|
||||||
<p class="vertical-center"> <pr-icon icon="'alert-triangle'" mode="'warning'"></pr-icon>This template type cannot be deployed on this environment. </p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<!-- !actions -->
|
|
||||||
</form>
|
|
||||||
</rd-widget-body>
|
|
||||||
</rd-widget>
|
|
||||||
</div>
|
|
|
@ -1,127 +0,0 @@
|
||||||
import _ from 'lodash-es';
|
|
||||||
|
|
||||||
angular.module('portainer.app').factory('TemplateHelper', [
|
|
||||||
function TemplateHelperFactory() {
|
|
||||||
'use strict';
|
|
||||||
var helper = {};
|
|
||||||
|
|
||||||
helper.getDefaultContainerConfiguration = function () {
|
|
||||||
return {
|
|
||||||
Env: [],
|
|
||||||
OpenStdin: false,
|
|
||||||
Tty: false,
|
|
||||||
ExposedPorts: {},
|
|
||||||
HostConfig: {
|
|
||||||
RestartPolicy: {
|
|
||||||
Name: 'no',
|
|
||||||
},
|
|
||||||
PortBindings: {},
|
|
||||||
Binds: [],
|
|
||||||
Privileged: false,
|
|
||||||
ExtraHosts: [],
|
|
||||||
},
|
|
||||||
Volumes: {},
|
|
||||||
Labels: {},
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
helper.portArrayToPortConfiguration = function (ports) {
|
|
||||||
var portConfiguration = {
|
|
||||||
bindings: {},
|
|
||||||
exposedPorts: {},
|
|
||||||
};
|
|
||||||
ports.forEach(function (p) {
|
|
||||||
if (p.containerPort) {
|
|
||||||
var key = p.containerPort + '/' + p.protocol;
|
|
||||||
var binding = {};
|
|
||||||
if (p.hostPort) {
|
|
||||||
binding.HostPort = p.hostPort;
|
|
||||||
if (p.hostPort.indexOf(':') > -1) {
|
|
||||||
var hostAndPort = p.hostPort.split(':');
|
|
||||||
binding.HostIp = hostAndPort[0];
|
|
||||||
binding.HostPort = hostAndPort[1];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
portConfiguration.bindings[key] = [binding];
|
|
||||||
portConfiguration.exposedPorts[key] = {};
|
|
||||||
}
|
|
||||||
});
|
|
||||||
return portConfiguration;
|
|
||||||
};
|
|
||||||
|
|
||||||
helper.updateContainerConfigurationWithLabels = function (labelsArray) {
|
|
||||||
var labels = {};
|
|
||||||
labelsArray.forEach(function (l) {
|
|
||||||
if (l.name) {
|
|
||||||
if (l.value) {
|
|
||||||
labels[l.name] = l.value;
|
|
||||||
} else {
|
|
||||||
labels[l.name] = '';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
return labels;
|
|
||||||
};
|
|
||||||
|
|
||||||
helper.EnvToStringArray = function (templateEnvironment) {
|
|
||||||
var env = [];
|
|
||||||
templateEnvironment.forEach(function (envvar) {
|
|
||||||
if (envvar.value || envvar.set) {
|
|
||||||
var value = envvar.set ? envvar.set : envvar.value;
|
|
||||||
env.push(envvar.name + '=' + value);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
return env;
|
|
||||||
};
|
|
||||||
|
|
||||||
helper.getConsoleConfiguration = function (interactiveFlag) {
|
|
||||||
var consoleConfiguration = {
|
|
||||||
openStdin: false,
|
|
||||||
tty: false,
|
|
||||||
};
|
|
||||||
if (interactiveFlag === true) {
|
|
||||||
consoleConfiguration.openStdin = true;
|
|
||||||
consoleConfiguration.tty = true;
|
|
||||||
}
|
|
||||||
return consoleConfiguration;
|
|
||||||
};
|
|
||||||
|
|
||||||
helper.createVolumeBindings = function (volumes, generatedVolumesPile) {
|
|
||||||
volumes.forEach(function (volume) {
|
|
||||||
if (volume.container) {
|
|
||||||
var binding;
|
|
||||||
if (volume.type === 'auto') {
|
|
||||||
binding = generatedVolumesPile.pop().Id + ':' + volume.container;
|
|
||||||
} else if (volume.type !== 'auto' && volume.bind) {
|
|
||||||
binding = volume.bind + ':' + volume.container;
|
|
||||||
}
|
|
||||||
if (volume.readonly) {
|
|
||||||
binding += ':ro';
|
|
||||||
}
|
|
||||||
volume.binding = binding;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
helper.determineRequiredGeneratedVolumeCount = function (volumes) {
|
|
||||||
var count = 0;
|
|
||||||
volumes.forEach(function (volume) {
|
|
||||||
if (volume.type === 'auto') {
|
|
||||||
++count;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
return count;
|
|
||||||
};
|
|
||||||
|
|
||||||
helper.getUniqueCategories = function (templates) {
|
|
||||||
var categories = [];
|
|
||||||
for (var i = 0; i < templates.length; i++) {
|
|
||||||
var template = templates[i];
|
|
||||||
categories = categories.concat(template.Categories);
|
|
||||||
}
|
|
||||||
return _.uniq(categories);
|
|
||||||
};
|
|
||||||
|
|
||||||
return helper;
|
|
||||||
},
|
|
||||||
]);
|
|
|
@ -13,7 +13,6 @@ import {
|
||||||
import { PlatformField } from '@/react/portainer/custom-templates/components/PlatformSelector';
|
import { PlatformField } from '@/react/portainer/custom-templates/components/PlatformSelector';
|
||||||
import { TemplateTypeSelector } from '@/react/portainer/custom-templates/components/TemplateTypeSelector';
|
import { TemplateTypeSelector } from '@/react/portainer/custom-templates/components/TemplateTypeSelector';
|
||||||
import { withFormValidation } from '@/react-tools/withFormValidation';
|
import { withFormValidation } from '@/react-tools/withFormValidation';
|
||||||
import { AppTemplatesList } from '@/react/portainer/templates/app-templates/AppTemplatesList';
|
|
||||||
import { CustomTemplatesList } from '@/react/portainer/templates/custom-templates/ListView/CustomTemplatesList';
|
import { CustomTemplatesList } from '@/react/portainer/templates/custom-templates/ListView/CustomTemplatesList';
|
||||||
|
|
||||||
import { VariablesFieldAngular } from './variables-field';
|
import { VariablesFieldAngular } from './variables-field';
|
||||||
|
@ -39,18 +38,6 @@ export const ngModule = angular
|
||||||
'isVariablesNamesFromParent',
|
'isVariablesNamesFromParent',
|
||||||
])
|
])
|
||||||
)
|
)
|
||||||
.component(
|
|
||||||
'appTemplatesList',
|
|
||||||
r2a(withUIRouter(withCurrentUser(AppTemplatesList)), [
|
|
||||||
'onSelect',
|
|
||||||
'templates',
|
|
||||||
'selectedId',
|
|
||||||
'disabledTypes',
|
|
||||||
'fixedCategories',
|
|
||||||
'storageKey',
|
|
||||||
'templateLinkParams',
|
|
||||||
])
|
|
||||||
)
|
|
||||||
.component(
|
.component(
|
||||||
'customTemplatesList',
|
'customTemplatesList',
|
||||||
r2a(withUIRouter(withCurrentUser(CustomTemplatesList)), [
|
r2a(withUIRouter(withCurrentUser(CustomTemplatesList)), [
|
||||||
|
|
|
@ -19,6 +19,7 @@ import { updateSchedulesModule } from './update-schedules';
|
||||||
import { environmentGroupModule } from './env-groups';
|
import { environmentGroupModule } from './env-groups';
|
||||||
import { registriesModule } from './registries';
|
import { registriesModule } from './registries';
|
||||||
import { activityLogsModule } from './activity-logs';
|
import { activityLogsModule } from './activity-logs';
|
||||||
|
import { templatesModule } from './templates';
|
||||||
|
|
||||||
export const viewsModule = angular
|
export const viewsModule = angular
|
||||||
.module('portainer.app.react.views', [
|
.module('portainer.app.react.views', [
|
||||||
|
@ -28,6 +29,7 @@ export const viewsModule = angular
|
||||||
environmentGroupModule,
|
environmentGroupModule,
|
||||||
registriesModule,
|
registriesModule,
|
||||||
activityLogsModule,
|
activityLogsModule,
|
||||||
|
templatesModule,
|
||||||
])
|
])
|
||||||
.component(
|
.component(
|
||||||
'homeView',
|
'homeView',
|
||||||
|
|
|
@ -0,0 +1,23 @@
|
||||||
|
import angular from 'angular';
|
||||||
|
|
||||||
|
import { r2a } from '@/react-tools/react2angular';
|
||||||
|
import { withCurrentUser } from '@/react-tools/withCurrentUser';
|
||||||
|
import { withUIRouter } from '@/react-tools/withUIRouter';
|
||||||
|
import { CreateView } from '@/react/portainer/templates/custom-templates/CreateView';
|
||||||
|
import { EditView } from '@/react/portainer/templates/custom-templates/EditView';
|
||||||
|
import { AppTemplatesView } from '@/react/portainer/templates/app-templates/AppTemplatesView';
|
||||||
|
|
||||||
|
export const templatesModule = angular
|
||||||
|
.module('portainer.app.react.views.templates', [])
|
||||||
|
.component(
|
||||||
|
'appTemplatesView',
|
||||||
|
r2a(withCurrentUser(withUIRouter(AppTemplatesView)), [])
|
||||||
|
)
|
||||||
|
.component(
|
||||||
|
'createCustomTemplatesView',
|
||||||
|
r2a(withCurrentUser(withUIRouter(CreateView)), [])
|
||||||
|
)
|
||||||
|
.component(
|
||||||
|
'editCustomTemplatesView',
|
||||||
|
r2a(withCurrentUser(withUIRouter(EditView)), [])
|
||||||
|
).name;
|
|
@ -1,11 +1,10 @@
|
||||||
import { commandStringToArray } from '@/docker/helpers/containers';
|
|
||||||
import { TemplateViewModel } from '@/react/portainer/templates/app-templates/view-model';
|
import { TemplateViewModel } from '@/react/portainer/templates/app-templates/view-model';
|
||||||
import { DockerHubViewModel } from 'Portainer/models/dockerhub';
|
import { DockerHubViewModel } from 'Portainer/models/dockerhub';
|
||||||
|
|
||||||
angular.module('portainer.app').factory('TemplateService', TemplateServiceFactory);
|
angular.module('portainer.app').factory('TemplateService', TemplateServiceFactory);
|
||||||
|
|
||||||
/* @ngInject */
|
/* @ngInject */
|
||||||
function TemplateServiceFactory($q, Templates, TemplateHelper, ImageHelper, ContainerHelper, EndpointService) {
|
function TemplateServiceFactory($q, Templates, EndpointService) {
|
||||||
var service = {
|
var service = {
|
||||||
templates,
|
templates,
|
||||||
};
|
};
|
||||||
|
@ -45,43 +44,5 @@ function TemplateServiceFactory($q, Templates, TemplateHelper, ImageHelper, Cont
|
||||||
return Templates.file({ repositoryUrl, composeFilePathInRepository }).$promise;
|
return Templates.file({ repositoryUrl, composeFilePathInRepository }).$promise;
|
||||||
}
|
}
|
||||||
|
|
||||||
service.createTemplateConfiguration = function (template, containerName, network) {
|
|
||||||
var imageConfiguration = ImageHelper.createImageConfigForContainer(template.RegistryModel);
|
|
||||||
var containerConfiguration = createContainerConfiguration(template, containerName, network);
|
|
||||||
containerConfiguration.Image = imageConfiguration.fromImage;
|
|
||||||
return containerConfiguration;
|
|
||||||
};
|
|
||||||
|
|
||||||
function createContainerConfiguration(template, containerName, network) {
|
|
||||||
var configuration = TemplateHelper.getDefaultContainerConfiguration();
|
|
||||||
configuration.HostConfig.NetworkMode = network.Name;
|
|
||||||
configuration.HostConfig.Privileged = template.Privileged;
|
|
||||||
configuration.HostConfig.RestartPolicy = { Name: template.RestartPolicy };
|
|
||||||
configuration.HostConfig.ExtraHosts = template.Hosts ? template.Hosts : [];
|
|
||||||
configuration.name = containerName;
|
|
||||||
configuration.Hostname = template.Hostname;
|
|
||||||
configuration.Env = TemplateHelper.EnvToStringArray(template.Env);
|
|
||||||
configuration.Cmd = commandStringToArray(template.Command);
|
|
||||||
var portConfiguration = TemplateHelper.portArrayToPortConfiguration(template.Ports);
|
|
||||||
configuration.HostConfig.PortBindings = portConfiguration.bindings;
|
|
||||||
configuration.ExposedPorts = portConfiguration.exposedPorts;
|
|
||||||
var consoleConfiguration = TemplateHelper.getConsoleConfiguration(template.Interactive);
|
|
||||||
configuration.OpenStdin = consoleConfiguration.openStdin;
|
|
||||||
configuration.Tty = consoleConfiguration.tty;
|
|
||||||
configuration.Labels = TemplateHelper.updateContainerConfigurationWithLabels(template.Labels);
|
|
||||||
return configuration;
|
|
||||||
}
|
|
||||||
|
|
||||||
service.updateContainerConfigurationWithVolumes = function (configuration, template, generatedVolumesPile) {
|
|
||||||
var volumes = template.Volumes;
|
|
||||||
TemplateHelper.createVolumeBindings(volumes, generatedVolumesPile);
|
|
||||||
volumes.forEach(function (volume) {
|
|
||||||
if (volume.binding) {
|
|
||||||
configuration.Volumes[volume.container] = {};
|
|
||||||
configuration.HostConfig.Binds.push(volume.binding);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
return service;
|
return service;
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,67 +1,10 @@
|
||||||
<page-header title="'Custom Templates'" breadcrumbs="['Custom Templates']" reload="true"> </page-header>
|
<page-header title="'Custom Templates'" breadcrumbs="['Custom Templates']" reload="true"> </page-header>
|
||||||
|
|
||||||
<div class="row">
|
<stack-from-custom-template-form-widget
|
||||||
<stack-from-template-form
|
ng-if="$ctrl.state.selectedTemplate"
|
||||||
ng-if="$ctrl.state.selectedTemplate"
|
template="$ctrl.state.selectedTemplate"
|
||||||
template="$ctrl.state.selectedTemplate"
|
unselect="$ctrl.unselectTemplate"
|
||||||
form-values="$ctrl.formValues"
|
></stack-from-custom-template-form-widget>
|
||||||
name-regex="$ctrl.state.templateNameRegex"
|
|
||||||
state="$ctrl.state"
|
|
||||||
create-template="$ctrl.createStack"
|
|
||||||
unselect-template="$ctrl.unselectTemplate"
|
|
||||||
>
|
|
||||||
<advanced-form>
|
|
||||||
<custom-templates-variables-field
|
|
||||||
ng-if="$ctrl.isTemplateVariablesEnabled"
|
|
||||||
definitions="$ctrl.state.selectedTemplate.Variables"
|
|
||||||
value="$ctrl.formValues.variables"
|
|
||||||
on-change="($ctrl.onChangeTemplateVariables)"
|
|
||||||
></custom-templates-variables-field>
|
|
||||||
|
|
||||||
<div class="form-group" ng-if="$ctrl.state.selectedTemplate && !$ctrl.state.templateLoadFailed">
|
|
||||||
<div class="col-sm-12">
|
|
||||||
<a class="small interactive vertical-center" ng-show="!$ctrl.state.showAdvancedOptions" ng-click="$ctrl.state.showAdvancedOptions = true;">
|
|
||||||
<pr-icon icon="'plus'" class-name="space-right" feather="true"></pr-icon> {{ $ctrl.state.selectedTemplate.GitConfig !== null ? 'View' : 'Customize' }} stack
|
|
||||||
</a>
|
|
||||||
<a class="small interactive vertical-center" ng-show="$ctrl.state.showAdvancedOptions" ng-click="$ctrl.state.showAdvancedOptions = false;">
|
|
||||||
<pr-icon icon="'minus'" class-name="space-right" feather="true"></pr-icon> Hide {{ $ctrl.state.selectedTemplate.GitConfig === null ? 'custom' : '' }} stack
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<span ng-if="$ctrl.state.selectedTemplate && $ctrl.state.templateLoadFailed">
|
|
||||||
<p class="small vertical-center text-danger mb-5" ng-if="$ctrl.currentUser.isAdmin || $ctrl.currentUser.id === $ctrl.state.selectedTemplate.CreatedByUserId">
|
|
||||||
<pr-icon icon="'alert-triangle'" mode="'danger'" size="'md'" feather="true"></pr-icon>Custom template could not be loaded, please
|
|
||||||
<a ui-sref="docker.templates.custom.edit({id: $ctrl.state.selectedTemplate.Id})">click here</a> for configuration.</p
|
|
||||||
>
|
|
||||||
<p class="small vertical-center text-danger mb-5" ng-if="!($ctrl.currentUser.isAdmin || $ctrl.currentUser.id === $ctrl.state.selectedTemplate.CreatedByUserId)">
|
|
||||||
<pr-icon icon="'alert-triangle'" mode="'danger'" size="'md'" feather="true"></pr-icon>Custom template could not be loaded, please contact your administrator.</p
|
|
||||||
>
|
|
||||||
</span>
|
|
||||||
|
|
||||||
<!-- web-editor -->
|
|
||||||
<web-editor-form
|
|
||||||
ng-if="$ctrl.state.showAdvancedOptions"
|
|
||||||
identifier="custom-template-creation-editor"
|
|
||||||
value="$ctrl.formValues.fileContent"
|
|
||||||
on-change="($ctrl.editorUpdate)"
|
|
||||||
ng-required="true"
|
|
||||||
yml="true"
|
|
||||||
placeholder="Define or paste the content of your docker compose file here"
|
|
||||||
read-only="$ctrl.state.isEditorReadOnly"
|
|
||||||
>
|
|
||||||
<editor-description>
|
|
||||||
<p>
|
|
||||||
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>
|
|
||||||
.
|
|
||||||
</p>
|
|
||||||
</editor-description>
|
|
||||||
</web-editor-form>
|
|
||||||
<!-- !web-editor -->
|
|
||||||
</advanced-form>
|
|
||||||
</stack-from-template-form>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<custom-templates-list
|
<custom-templates-list
|
||||||
templates="$ctrl.templates"
|
templates="$ctrl.templates"
|
||||||
|
|
|
@ -228,6 +228,8 @@ class CustomTemplatesViewController {
|
||||||
const variables = getVariablesFieldDefaultValues(template.Variables);
|
const variables = getVariablesFieldDefaultValues(template.Variables);
|
||||||
this.onChangeTemplateVariables(variables);
|
this.onChangeTemplateVariables(variables);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||||
}
|
}
|
||||||
|
|
||||||
getNetworks(provider, apiVersion) {
|
getNetworks(provider, apiVersion) {
|
||||||
|
|
|
@ -1,292 +0,0 @@
|
||||||
<page-header id="'view-top'" title="'Application templates list'" breadcrumbs="['Templates']"> </page-header>
|
|
||||||
|
|
||||||
<div class="row">
|
|
||||||
<!-- stack-form -->
|
|
||||||
<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">
|
|
||||||
<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" name="selectedTemplateType1">
|
|
||||||
<!-- description -->
|
|
||||||
<div ng-if="state.selectedTemplate.Note">
|
|
||||||
<div class="form-section-title"> Information </div>
|
|
||||||
<div class="col-sm-12 form-group">
|
|
||||||
<div class="template-note" ng-bind-html="state.selectedTemplate.Note"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<!-- !description -->
|
|
||||||
<div class="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-6">
|
|
||||||
<input type="text" name="container_name" class="form-control" ng-model="formValues.name" placeholder="e.g. web (optional)" data-cy="container-name-input" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<!-- !name-input -->
|
|
||||||
<!-- network-input -->
|
|
||||||
<div class="form-group">
|
|
||||||
<label for="container_network" class="col-sm-2 control-label text-left">Network</label>
|
|
||||||
<div class="col-sm-6">
|
|
||||||
<select class="form-control" ng-options="net.Name for net in availableNetworks | orderBy: 'Name'" ng-model="formValues.network" data-cy="network-select">
|
|
||||||
<option disabled hidden value="">Select a network</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<!-- !network-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" message="var.description"></portainer-tooltip>
|
|
||||||
</label>
|
|
||||||
<div class="col-sm-6">
|
|
||||||
<input type="text" class="form-control" ng-if="!var.select" ng-model="var.value" id="field_{{ $index }}" data-cy="env-input-{{ $index}" />
|
|
||||||
<select class="form-control" ng-if="var.select" ng-model="var.value" id="field_{{ $index }}" data-cy="env-select-{{ $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 -->
|
|
||||||
<div class="form-group col-sm-12">
|
|
||||||
<a class="small interactive vertical-center" ng-if="!state.showAdvancedOptions" ng-click="state.showAdvancedOptions = true;">
|
|
||||||
<pr-icon icon="'plus'"></pr-icon> Show advanced options
|
|
||||||
</a>
|
|
||||||
<a class="small interactive vertical-center" ng-if="state.showAdvancedOptions" ng-click="state.showAdvancedOptions = false;">
|
|
||||||
<pr-icon icon="'minus'"></pr-icon> Hide advanced options
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
<div ng-if="state.showAdvancedOptions">
|
|
||||||
<!-- port-mapping -->
|
|
||||||
<div class="form-group mt-2">
|
|
||||||
<div class="col-sm-12">
|
|
||||||
<label class="control-label text-left">Port mapping</label>
|
|
||||||
<div class="mt-1" ng-if="state.selectedTemplate.Ports.length > 0">
|
|
||||||
<div class="small text-muted">Portainer will automatically assign a port if you leave the host port empty.</div>
|
|
||||||
<div class="form-inline mt-2" ng-repeat="portBinding in state.selectedTemplate.Ports">
|
|
||||||
<!-- host-port -->
|
|
||||||
<div class="input-group col-sm-4 input-group-sm">
|
|
||||||
<span class="input-group-addon">host</span>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
class="form-control"
|
|
||||||
ng-model="portBinding.hostPort"
|
|
||||||
placeholder="e.g. 80 or 1.2.3.4:80 (optional)"
|
|
||||||
data-cy="host-port-input-{{ $index }}"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<!-- !host-port -->
|
|
||||||
<pr-icon icon="'arrow-right'"></pr-icon>
|
|
||||||
<!-- container-port -->
|
|
||||||
<div class="input-group col-sm-4 input-group-sm">
|
|
||||||
<span class="input-group-addon">container</span>
|
|
||||||
<input type="text" class="form-control" ng-model="portBinding.containerPort" placeholder="e.g. 80" data-cy="container-port-input-{{ $index }}" />
|
|
||||||
</div>
|
|
||||||
<!-- !container-port -->
|
|
||||||
<!-- protocol-actions -->
|
|
||||||
<div class="input-group col-sm-3 input-group-sm">
|
|
||||||
<div class="btn-group btn-group-sm">
|
|
||||||
<label class="btn btn-light" ng-model="portBinding.protocol" uib-btn-radio="'tcp'">TCP</label>
|
|
||||||
<label class="btn btn-light" ng-model="portBinding.protocol" uib-btn-radio="'udp'">UDP</label>
|
|
||||||
</div>
|
|
||||||
<button class="btn btn-light" type="button" ng-click="removePortBinding($index)">
|
|
||||||
<pr-icon icon="'trash-2'" class-name="'icon-secondary icon-md'"></pr-icon>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<!-- !protocol-actions -->
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="col-sm-12">
|
|
||||||
<span class="form-group small interactive text-muted vertical-center mt-2" ng-click="addPortBinding()">
|
|
||||||
<pr-icon icon="'plus'"></pr-icon> Add map additional port
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<!-- !port-mapping -->
|
|
||||||
<!-- volume-mapping -->
|
|
||||||
<div class="form-group mt-4">
|
|
||||||
<div class="col-sm-12">
|
|
||||||
<label class="control-label text-left">Volume mapping</label>
|
|
||||||
<div class="mt-1" ng-if="state.selectedTemplate.Volumes.length > 0">
|
|
||||||
<div class="small text-muted">Portainer will automatically create and map a local volume when using the <b>auto</b> option.</div>
|
|
||||||
<div class="mt-2" ng-repeat="volume in state.selectedTemplate.Volumes">
|
|
||||||
<!-- volume-line1 -->
|
|
||||||
<div class="form-inline">
|
|
||||||
<!-- container-path -->
|
|
||||||
<div class="input-group input-group-sm col-sm-6">
|
|
||||||
<span class="input-group-addon">container</span>
|
|
||||||
<input type="text" class="form-control" ng-model="volume.container" placeholder="e.g. /path/in/container" data-cy="container-path-input-{{ $index }}" />
|
|
||||||
</div>
|
|
||||||
<!-- !container-path -->
|
|
||||||
<!-- volume-type -->
|
|
||||||
<div class="input-group col-sm-5 space-left">
|
|
||||||
<div class="btn-group btn-group-sm">
|
|
||||||
<label class="btn btn-light" ng-model="volume.type" uib-btn-radio="'auto'" ng-click="volume.bind = ''">Auto</label>
|
|
||||||
<label class="btn btn-light" ng-model="volume.type" uib-btn-radio="'volume'" ng-click="volume.bind = ''">Volume</label>
|
|
||||||
<label class="btn btn-light" ng-model="volume.type" uib-btn-radio="'bind'" ng-click="volume.bind = ''" ng-if="isAdmin || allowBindMounts">Bind</label>
|
|
||||||
</div>
|
|
||||||
<button class="btn btn-light" type="button" ng-click="removeVolume($index)">
|
|
||||||
<pr-icon icon="'trash-2'" class-name="'icon-secondary icon-md'"></pr-icon>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<!-- !volume-type -->
|
|
||||||
</div>
|
|
||||||
<!-- !volume-line1 -->
|
|
||||||
<!-- volume-line2 -->
|
|
||||||
<div class="form-inline mt-1" ng-if="volume.type !== 'auto'">
|
|
||||||
<pr-icon icon="'arrow-right'"></pr-icon>
|
|
||||||
<!-- volume -->
|
|
||||||
<div class="input-group input-group-sm col-sm-6" ng-if="volume.type === 'volume'">
|
|
||||||
<div class="col-sm-12 input-group">
|
|
||||||
<span class="input-group-addon">volume</span>
|
|
||||||
<div class="col-sm-12 input-group">
|
|
||||||
<select class="form-control" ng-model="volume.bind" ng-options="vol.Name as vol.Name for vol in availableVolumes" data-cy="volume-bind-select">
|
|
||||||
<option value="" disabled selected>Select a volume</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<!-- !volume -->
|
|
||||||
<!-- bind -->
|
|
||||||
<div class="input-group input-group-sm col-sm-6" ng-if="volume.type === 'bind'">
|
|
||||||
<span class="input-group-addon">host</span>
|
|
||||||
<input type="text" class="form-control" ng-model="volume.bind" placeholder="e.g. /path/on/host" data-cy="host-path-input-{{ $index }}" />
|
|
||||||
</div>
|
|
||||||
<!-- !bind -->
|
|
||||||
<!-- read-only -->
|
|
||||||
<div class="input-group input-group-sm col-sm-5 space-left">
|
|
||||||
<div class="btn-group btn-group-sm">
|
|
||||||
<label class="btn btn-light" ng-model="volume.readonly" uib-btn-radio="false">Writable</label>
|
|
||||||
<label class="btn btn-light" ng-model="volume.readonly" uib-btn-radio="true">Read-only</label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<!-- !read-only -->
|
|
||||||
</div>
|
|
||||||
<!-- !volume-line2 -->
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="col-sm-12">
|
|
||||||
<span class="form-group small interactive text-muted vertical-center mt-2" ng-click="addVolume()">
|
|
||||||
<pr-icon icon="'plus'"></pr-icon> Add map additional volume
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<!-- !volume-mapping -->
|
|
||||||
<!-- extra-host -->
|
|
||||||
<div class="form-group mt-4">
|
|
||||||
<div class="col-sm-12">
|
|
||||||
<label class="control-label text-left">Hosts file entries</label>
|
|
||||||
<!-- extra-host-input-list -->
|
|
||||||
<div class="mt-1" ng-if="state.selectedTemplate.Hosts.length > 0">
|
|
||||||
<div class="form-inline mt-2" ng-repeat="(idx, host) in state.selectedTemplate.Hosts track by $index">
|
|
||||||
<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="state.selectedTemplate.Hosts[idx]" placeholder="e.g. host:IP" data-cy="host-input-{{ $index }}" />
|
|
||||||
</div>
|
|
||||||
<button class="btn btn-light" type="button" ng-click="removeExtraHost($index)">
|
|
||||||
<pr-icon icon="'trash-2'" class-name="'icon-secondary icon-md'"></pr-icon>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="col-sm-12">
|
|
||||||
<span class="form-group small interactive text-muted vertical-center mt-2" ng-click="addExtraHost()">
|
|
||||||
<pr-icon icon="'plus'"></pr-icon> Add additional entry
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<!-- !extra-host -->
|
|
||||||
<!-- labels -->
|
|
||||||
<div class="form-group mt-4">
|
|
||||||
<div class="col-sm-12">
|
|
||||||
<label class="control-label text-left">Labels</label>
|
|
||||||
<!-- labels-input-list -->
|
|
||||||
<div class="mt-1" ng-if="state.selectedTemplate.Labels.length > 0">
|
|
||||||
<div class="form-inline mt-2" ng-repeat="label in state.selectedTemplate.Labels">
|
|
||||||
<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="label.name" placeholder="e.g. com.example.foo" data-cy="label-name-input-{{ $index }}" />
|
|
||||||
</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="label.value" placeholder="e.g. bar" data-cy="label-value-input-{{ $index }}" />
|
|
||||||
</div>
|
|
||||||
<button class="btn btn-light" type="button" ng-click="removeLabel($index)">
|
|
||||||
<pr-icon icon="'trash-2'" class-name="'icon-secondary icon-md'"></pr-icon>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<!-- !labels-input-list -->
|
|
||||||
<div class="col-sm-12">
|
|
||||||
<span class="form-group small interactive text-muted vertical-center mt-2" ng-click="addLabel()"> <pr-icon icon="'plus'"></pr-icon> Add label </span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<!-- !labels -->
|
|
||||||
<!-- hostname -->
|
|
||||||
<div class="form-group mt-4">
|
|
||||||
<label for="container_hostname" class="col-sm-2 control-label text-left">Hostname</label>
|
|
||||||
<div class="col-sm-6">
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
name="container_hostname"
|
|
||||||
class="form-control"
|
|
||||||
ng-model="state.selectedTemplate.Hostname"
|
|
||||||
placeholder="leave empty to use docker default"
|
|
||||||
data-cy="hostname-input"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<!-- !hostname -->
|
|
||||||
</div>
|
|
||||||
<!-- !advanced-options -->
|
|
||||||
<!-- actions -->
|
|
||||||
<div class="form-section-title"> Actions </div>
|
|
||||||
<div class="form-group">
|
|
||||||
<div class="col-sm-12">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="btn btn-primary"
|
|
||||||
ng-disabled="state.actionInProgress || !formValues.network"
|
|
||||||
ng-click="createTemplate()"
|
|
||||||
button-spinner="state.actionInProgress"
|
|
||||||
>
|
|
||||||
<span ng-hide="state.actionInProgress">Deploy the container</span>
|
|
||||||
<span ng-show="state.actionInProgress">Deployment in progress...</span>
|
|
||||||
</button>
|
|
||||||
<button type="button" class="btn btn-default" ng-click="unselectTemplate(state.selectedTemplate)">Hide</button>
|
|
||||||
<span class="text-danger space-left" ng-if="state.formValidationError">{{ state.formValidationError }}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<!-- !actions -->
|
|
||||||
</form>
|
|
||||||
</rd-widget-body>
|
|
||||||
</rd-widget>
|
|
||||||
</div>
|
|
||||||
<!-- container-form -->
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<app-templates-list
|
|
||||||
storage-key="'docker-app-templates'"
|
|
||||||
templates="templates"
|
|
||||||
on-select="(selectTemplate)"
|
|
||||||
selected-id="state.selectedTemplate.Id"
|
|
||||||
disabled-types="disabledTypes"
|
|
||||||
></app-templates-list>
|
|
|
@ -1,317 +0,0 @@
|
||||||
import _ from 'lodash-es';
|
|
||||||
import { TemplateType } from '@/react/portainer/templates/app-templates/types';
|
|
||||||
import { AccessControlFormData } from '../../components/accessControlForm/porAccessControlFormModel';
|
|
||||||
|
|
||||||
angular.module('portainer.app').controller('TemplatesController', [
|
|
||||||
'$scope',
|
|
||||||
'$q',
|
|
||||||
'$state',
|
|
||||||
'$anchorScroll',
|
|
||||||
'ContainerService',
|
|
||||||
'ImageService',
|
|
||||||
'NetworkService',
|
|
||||||
'TemplateService',
|
|
||||||
'TemplateHelper',
|
|
||||||
'VolumeService',
|
|
||||||
'Notifications',
|
|
||||||
'ResourceControlService',
|
|
||||||
'Authentication',
|
|
||||||
'FormValidator',
|
|
||||||
'StackService',
|
|
||||||
'endpoint',
|
|
||||||
'$async',
|
|
||||||
function (
|
|
||||||
$scope,
|
|
||||||
$q,
|
|
||||||
$state,
|
|
||||||
$anchorScroll,
|
|
||||||
ContainerService,
|
|
||||||
ImageService,
|
|
||||||
NetworkService,
|
|
||||||
TemplateService,
|
|
||||||
TemplateHelper,
|
|
||||||
VolumeService,
|
|
||||||
Notifications,
|
|
||||||
ResourceControlService,
|
|
||||||
Authentication,
|
|
||||||
FormValidator,
|
|
||||||
StackService,
|
|
||||||
endpoint,
|
|
||||||
$async
|
|
||||||
) {
|
|
||||||
const DOCKER_STANDALONE = 'DOCKER_STANDALONE';
|
|
||||||
const DOCKER_SWARM_MODE = 'DOCKER_SWARM_MODE';
|
|
||||||
|
|
||||||
$scope.state = {
|
|
||||||
selectedTemplate: null,
|
|
||||||
showAdvancedOptions: false,
|
|
||||||
formValidationError: '',
|
|
||||||
actionInProgress: false,
|
|
||||||
};
|
|
||||||
|
|
||||||
$scope.enabledTypes = [TemplateType.Container, TemplateType.ComposeStack];
|
|
||||||
|
|
||||||
$scope.formValues = {
|
|
||||||
network: '',
|
|
||||||
name: '',
|
|
||||||
AccessControlData: new AccessControlFormData(),
|
|
||||||
};
|
|
||||||
|
|
||||||
$scope.addVolume = function () {
|
|
||||||
$scope.state.selectedTemplate.Volumes.push({ containerPath: '', bind: '', readonly: false, type: 'auto' });
|
|
||||||
};
|
|
||||||
|
|
||||||
$scope.removeVolume = function (index) {
|
|
||||||
$scope.state.selectedTemplate.Volumes.splice(index, 1);
|
|
||||||
};
|
|
||||||
|
|
||||||
$scope.addPortBinding = function () {
|
|
||||||
$scope.state.selectedTemplate.Ports.push({ hostPort: '', containerPort: '', protocol: 'tcp' });
|
|
||||||
};
|
|
||||||
|
|
||||||
$scope.removePortBinding = function (index) {
|
|
||||||
$scope.state.selectedTemplate.Ports.splice(index, 1);
|
|
||||||
};
|
|
||||||
|
|
||||||
$scope.addExtraHost = function () {
|
|
||||||
$scope.state.selectedTemplate.Hosts.push('');
|
|
||||||
};
|
|
||||||
|
|
||||||
$scope.removeExtraHost = function (index) {
|
|
||||||
$scope.state.selectedTemplate.Hosts.splice(index, 1);
|
|
||||||
};
|
|
||||||
|
|
||||||
$scope.addLabel = function () {
|
|
||||||
$scope.state.selectedTemplate.Labels.push({ name: '', value: '' });
|
|
||||||
};
|
|
||||||
|
|
||||||
$scope.removeLabel = function (index) {
|
|
||||||
$scope.state.selectedTemplate.Labels.splice(index, 1);
|
|
||||||
};
|
|
||||||
|
|
||||||
function validateForm(accessControlData, isAdmin) {
|
|
||||||
$scope.state.formValidationError = '';
|
|
||||||
var error = '';
|
|
||||||
error = FormValidator.validateAccessControl(accessControlData, isAdmin);
|
|
||||||
|
|
||||||
if (error) {
|
|
||||||
$scope.state.formValidationError = error;
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
function createContainerFromTemplate(template, userId, accessControlData) {
|
|
||||||
var templateConfiguration = createTemplateConfiguration(template);
|
|
||||||
var generatedVolumeCount = TemplateHelper.determineRequiredGeneratedVolumeCount(template.Volumes);
|
|
||||||
var generatedVolumeIds = [];
|
|
||||||
VolumeService.createXAutoGeneratedLocalVolumes(generatedVolumeCount)
|
|
||||||
.then(function success(data) {
|
|
||||||
angular.forEach(data, function (volume) {
|
|
||||||
var volumeId = volume.Id;
|
|
||||||
generatedVolumeIds.push(volumeId);
|
|
||||||
});
|
|
||||||
TemplateService.updateContainerConfigurationWithVolumes(templateConfiguration, template, data);
|
|
||||||
return ImageService.pullImage(template.RegistryModel, true);
|
|
||||||
})
|
|
||||||
.then(function success() {
|
|
||||||
return ContainerService.createAndStartContainer(endpoint.Id, templateConfiguration);
|
|
||||||
})
|
|
||||||
.then(function success(data) {
|
|
||||||
const resourceControl = data.Portainer.ResourceControl;
|
|
||||||
return ResourceControlService.applyResourceControl(userId, accessControlData, resourceControl, generatedVolumeIds);
|
|
||||||
})
|
|
||||||
.then(function success() {
|
|
||||||
Notifications.success('Success', 'Container successfully created');
|
|
||||||
$state.go('docker.containers', {}, { reload: true });
|
|
||||||
})
|
|
||||||
.catch(function error(err) {
|
|
||||||
Notifications.error('Failure', err, err.msg);
|
|
||||||
})
|
|
||||||
.finally(function final() {
|
|
||||||
$scope.state.actionInProgress = false;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function createComposeStackFromTemplate(template, userId, accessControlData) {
|
|
||||||
var stackName = $scope.formValues.name;
|
|
||||||
|
|
||||||
for (var i = 0; i < template.Env.length; i++) {
|
|
||||||
var envvar = template.Env[i];
|
|
||||||
if (envvar.preset) {
|
|
||||||
envvar.value = envvar.default;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var repositoryOptions = {
|
|
||||||
RepositoryURL: template.Repository.url,
|
|
||||||
ComposeFilePathInRepository: template.Repository.stackfile,
|
|
||||||
FromAppTemplate: true,
|
|
||||||
};
|
|
||||||
|
|
||||||
const endpointId = +$state.params.endpointId;
|
|
||||||
StackService.createComposeStackFromGitRepository(stackName, repositoryOptions, template.Env, endpointId)
|
|
||||||
.then(function success(data) {
|
|
||||||
const resourceControl = data.ResourceControl;
|
|
||||||
return ResourceControlService.applyResourceControl(userId, accessControlData, resourceControl);
|
|
||||||
})
|
|
||||||
.then(function success() {
|
|
||||||
Notifications.success('Success', 'Stack successfully deployed');
|
|
||||||
$state.go('docker.stacks');
|
|
||||||
})
|
|
||||||
.catch(function error(err) {
|
|
||||||
Notifications.error('Deployment error', err);
|
|
||||||
})
|
|
||||||
.finally(function final() {
|
|
||||||
$scope.state.actionInProgress = false;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function createStackFromTemplate(template, userId, accessControlData) {
|
|
||||||
var stackName = $scope.formValues.name;
|
|
||||||
var env = _.filter(
|
|
||||||
_.map(template.Env, function transformEnvVar(envvar) {
|
|
||||||
return {
|
|
||||||
name: envvar.name,
|
|
||||||
value: envvar.preset || !envvar.value ? envvar.default : envvar.value,
|
|
||||||
};
|
|
||||||
}),
|
|
||||||
function removeUndefinedVars(envvar) {
|
|
||||||
return envvar.value && envvar.name;
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
var repositoryOptions = {
|
|
||||||
RepositoryURL: template.Repository.url,
|
|
||||||
ComposeFilePathInRepository: template.Repository.stackfile,
|
|
||||||
FromAppTemplate: true,
|
|
||||||
};
|
|
||||||
|
|
||||||
const endpointId = +$state.params.endpointId;
|
|
||||||
|
|
||||||
StackService.createSwarmStackFromGitRepository(stackName, repositoryOptions, env, endpointId)
|
|
||||||
.then(function success(data) {
|
|
||||||
const resourceControl = data.ResourceControl;
|
|
||||||
return ResourceControlService.applyResourceControl(userId, accessControlData, resourceControl);
|
|
||||||
})
|
|
||||||
.then(function success() {
|
|
||||||
Notifications.success('Success', 'Stack successfully deployed');
|
|
||||||
$state.go('docker.stacks');
|
|
||||||
})
|
|
||||||
.catch(function error(err) {
|
|
||||||
Notifications.error('Deployment error', err);
|
|
||||||
})
|
|
||||||
.finally(function final() {
|
|
||||||
$scope.state.actionInProgress = false;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
$scope.createTemplate = function () {
|
|
||||||
var userDetails = Authentication.getUserDetails();
|
|
||||||
var userId = userDetails.ID;
|
|
||||||
var accessControlData = $scope.formValues.AccessControlData;
|
|
||||||
|
|
||||||
if (!validateForm(accessControlData, $scope.isAdmin)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
var template = $scope.state.selectedTemplate;
|
|
||||||
|
|
||||||
$scope.state.actionInProgress = true;
|
|
||||||
if (template.Type === 2) {
|
|
||||||
createStackFromTemplate(template, userId, accessControlData);
|
|
||||||
} else if (template.Type === 3) {
|
|
||||||
createComposeStackFromTemplate(template, userId, accessControlData);
|
|
||||||
} else {
|
|
||||||
createContainerFromTemplate(template, userId, accessControlData);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
$scope.unselectTemplate = function () {
|
|
||||||
return $async(async () => {
|
|
||||||
$scope.state.selectedTemplate = null;
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
$scope.selectTemplate = function (template) {
|
|
||||||
return $async(async () => {
|
|
||||||
if ($scope.state.selectedTemplate) {
|
|
||||||
await $scope.unselectTemplate($scope.state.selectedTemplate);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (template.Network) {
|
|
||||||
$scope.formValues.network = _.find($scope.availableNetworks, function (o) {
|
|
||||||
return o.Name === template.Network;
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
$scope.formValues.network = _.find($scope.availableNetworks, function (o) {
|
|
||||||
return o.Name === 'bridge';
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
$scope.formValues.name = template.Name ? template.Name : '';
|
|
||||||
$scope.state.selectedTemplate = template;
|
|
||||||
$scope.state.deployable = isDeployable($scope.applicationState.endpoint, template.Type);
|
|
||||||
$anchorScroll('view-top');
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
function isDeployable(endpoint, templateType) {
|
|
||||||
let deployable = false;
|
|
||||||
switch (templateType) {
|
|
||||||
case 1:
|
|
||||||
deployable = endpoint.mode.provider === DOCKER_SWARM_MODE || endpoint.mode.provider === DOCKER_STANDALONE;
|
|
||||||
break;
|
|
||||||
case 2:
|
|
||||||
deployable = endpoint.mode.provider === DOCKER_SWARM_MODE;
|
|
||||||
break;
|
|
||||||
case 3:
|
|
||||||
deployable = endpoint.mode.provider === DOCKER_SWARM_MODE || endpoint.mode.provider === DOCKER_STANDALONE;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
return deployable;
|
|
||||||
}
|
|
||||||
|
|
||||||
function createTemplateConfiguration(template) {
|
|
||||||
var network = $scope.formValues.network;
|
|
||||||
var name = $scope.formValues.name;
|
|
||||||
return TemplateService.createTemplateConfiguration(template, name, network);
|
|
||||||
}
|
|
||||||
|
|
||||||
function initView() {
|
|
||||||
$scope.isAdmin = Authentication.isAdmin();
|
|
||||||
|
|
||||||
var endpointMode = $scope.applicationState.endpoint.mode;
|
|
||||||
var apiVersion = $scope.applicationState.endpoint.apiVersion;
|
|
||||||
const endpointId = +$state.params.endpointId;
|
|
||||||
|
|
||||||
const showSwarmStacks = endpointMode.provider === 'DOCKER_SWARM_MODE' && endpointMode.role === 'MANAGER' && apiVersion >= 1.25;
|
|
||||||
|
|
||||||
$scope.disabledTypes = !showSwarmStacks ? [TemplateType.SwarmStack] : [];
|
|
||||||
|
|
||||||
$q.all({
|
|
||||||
templates: TemplateService.templates(endpointId),
|
|
||||||
volumes: VolumeService.getVolumes(),
|
|
||||||
networks: NetworkService.networks(
|
|
||||||
endpointMode.provider === 'DOCKER_STANDALONE' || endpointMode.provider === 'DOCKER_SWARM_MODE',
|
|
||||||
false,
|
|
||||||
endpointMode.provider === 'DOCKER_SWARM_MODE' && apiVersion >= 1.25
|
|
||||||
),
|
|
||||||
})
|
|
||||||
.then(function success(data) {
|
|
||||||
var templates = data.templates;
|
|
||||||
$scope.templates = templates;
|
|
||||||
$scope.availableVolumes = _.orderBy(data.volumes.Volumes, [(volume) => volume.Name.toLowerCase()], ['asc']);
|
|
||||||
var networks = data.networks;
|
|
||||||
$scope.availableNetworks = networks;
|
|
||||||
$scope.allowBindMounts = endpoint.SecuritySettings.allowBindMountsForRegularUsers;
|
|
||||||
})
|
|
||||||
.catch(function error(err) {
|
|
||||||
$scope.templates = [];
|
|
||||||
Notifications.error('Failure', err, 'An error occurred during apps initialization.');
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
initView();
|
|
||||||
},
|
|
||||||
]);
|
|
|
@ -0,0 +1,58 @@
|
||||||
|
import { FormikErrors } from 'formik';
|
||||||
|
import { SchemaOf, string } from 'yup';
|
||||||
|
import { useMemo } from 'react';
|
||||||
|
|
||||||
|
import { STACK_NAME_VALIDATION_REGEX } from '@/react/constants';
|
||||||
|
import { EnvironmentId } from '@/react/portainer/environments/types';
|
||||||
|
|
||||||
|
import { FormControl } from '@@/form-components/FormControl';
|
||||||
|
import { Input } from '@@/form-components/Input';
|
||||||
|
|
||||||
|
import { useStacks } from '../queries/useStacks';
|
||||||
|
|
||||||
|
export function NameField({
|
||||||
|
onChange,
|
||||||
|
value,
|
||||||
|
errors,
|
||||||
|
}: {
|
||||||
|
onChange(value: string): void;
|
||||||
|
value: string;
|
||||||
|
errors?: FormikErrors<string>;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<FormControl inputId="name-input" label="Name" errors={errors} required>
|
||||||
|
<Input
|
||||||
|
id="name-input"
|
||||||
|
onChange={(e) => onChange(e.target.value)}
|
||||||
|
value={value}
|
||||||
|
required
|
||||||
|
data-cy="stack-name-input"
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useNameValidation(
|
||||||
|
environmentId: EnvironmentId
|
||||||
|
): SchemaOf<string> {
|
||||||
|
const stacksQuery = useStacks();
|
||||||
|
|
||||||
|
return useMemo(
|
||||||
|
() =>
|
||||||
|
string()
|
||||||
|
.required('Name is required')
|
||||||
|
.test(
|
||||||
|
'unique',
|
||||||
|
'Name should be unique',
|
||||||
|
(value) =>
|
||||||
|
stacksQuery.data?.every(
|
||||||
|
(s) => s.EndpointId !== environmentId || s.Name !== value
|
||||||
|
) ?? true
|
||||||
|
)
|
||||||
|
.matches(
|
||||||
|
new RegExp(STACK_NAME_VALIDATION_REGEX),
|
||||||
|
"This field must consist of lower case alphanumeric characters, '_' or '-' (e.g. 'my-name', or 'abc-123')."
|
||||||
|
),
|
||||||
|
[environmentId, stacksQuery.data]
|
||||||
|
);
|
||||||
|
}
|
|
@ -0,0 +1,7 @@
|
||||||
|
import { Stack } from '../types';
|
||||||
|
|
||||||
|
export function buildStackUrl(id?: Stack['Id'], action?: string) {
|
||||||
|
const baseUrl = '/stacks';
|
||||||
|
const url = id ? `${baseUrl}/${id}` : baseUrl;
|
||||||
|
return action ? `${url}/${action}` : url;
|
||||||
|
}
|
|
@ -0,0 +1,3 @@
|
||||||
|
export const queryKeys = {
|
||||||
|
base: () => ['stacks'],
|
||||||
|
};
|
|
@ -0,0 +1,14 @@
|
||||||
|
import { buildStackUrl } from '../buildUrl';
|
||||||
|
|
||||||
|
export function buildCreateUrl(
|
||||||
|
stackType: 'kubernetes',
|
||||||
|
method: 'repository' | 'url' | 'string'
|
||||||
|
): string;
|
||||||
|
|
||||||
|
export function buildCreateUrl(
|
||||||
|
stackType: 'swarm' | 'standalone',
|
||||||
|
method: 'repository' | 'string' | 'file'
|
||||||
|
): string;
|
||||||
|
export function buildCreateUrl(stackType: string, method: string) {
|
||||||
|
return buildStackUrl(undefined, `create/${stackType}/${method}`);
|
||||||
|
}
|
|
@ -0,0 +1,36 @@
|
||||||
|
import axios, { parseAxiosError } from '@/portainer/services/axios';
|
||||||
|
import { EnvironmentId } from '@/react/portainer/environments/types';
|
||||||
|
|
||||||
|
import { Stack } from '../../types';
|
||||||
|
|
||||||
|
import { buildCreateUrl } from './buildUrl';
|
||||||
|
|
||||||
|
export interface KubernetesFileContentPayload {
|
||||||
|
/** Name of the stack */
|
||||||
|
stackName: string;
|
||||||
|
/** Content of the Stack file */
|
||||||
|
stackFileContent: string;
|
||||||
|
composeFormat: boolean;
|
||||||
|
namespace: string;
|
||||||
|
/** Whether the stack is from an app template */
|
||||||
|
fromAppTemplate?: boolean;
|
||||||
|
environmentId: EnvironmentId;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createKubernetesStackFromFileContent({
|
||||||
|
environmentId,
|
||||||
|
...payload
|
||||||
|
}: KubernetesFileContentPayload) {
|
||||||
|
try {
|
||||||
|
const { data } = await axios.post<Stack>(
|
||||||
|
buildCreateUrl('kubernetes', 'string'),
|
||||||
|
payload,
|
||||||
|
{
|
||||||
|
params: { endpointId: environmentId },
|
||||||
|
}
|
||||||
|
);
|
||||||
|
return data;
|
||||||
|
} catch (e) {
|
||||||
|
throw parseAxiosError(e as Error);
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,54 @@
|
||||||
|
import axios, { parseAxiosError } from '@/portainer/services/axios';
|
||||||
|
import { AutoUpdateModel } from '@/react/portainer/gitops/types';
|
||||||
|
import { EnvironmentId } from '@/react/portainer/environments/types';
|
||||||
|
|
||||||
|
import { Stack } from '../../types';
|
||||||
|
|
||||||
|
import { buildCreateUrl } from './buildUrl';
|
||||||
|
|
||||||
|
export type KubernetesGitRepositoryPayload = {
|
||||||
|
/** Name of the stack */
|
||||||
|
stackName: string;
|
||||||
|
composeFormat: boolean;
|
||||||
|
namespace: string;
|
||||||
|
|
||||||
|
/** URL of a Git repository hosting the Stack file */
|
||||||
|
repositoryUrl: string;
|
||||||
|
/** Reference name of a Git repository hosting the Stack file */
|
||||||
|
repositoryReferenceName?: string;
|
||||||
|
/** Use basic authentication to clone the Git repository */
|
||||||
|
repositoryAuthentication?: boolean;
|
||||||
|
/** Username used in basic authentication. Required when RepositoryAuthentication is true. */
|
||||||
|
repositoryUsername?: string;
|
||||||
|
/** Password used in basic authentication. Required when RepositoryAuthentication is true. */
|
||||||
|
repositoryPassword?: string;
|
||||||
|
/** GitCredentialID used to identify the binded git credential */
|
||||||
|
repositoryGitCredentialId?: number;
|
||||||
|
/** Path to the Stack file inside the Git repository */
|
||||||
|
manifestFile?: string;
|
||||||
|
|
||||||
|
additionalFiles?: Array<string>;
|
||||||
|
/** TLSSkipVerify skips SSL verification when cloning the Git repository */
|
||||||
|
tlsSkipVerify?: boolean;
|
||||||
|
/** Optional GitOps update configuration */
|
||||||
|
autoUpdate?: AutoUpdateModel;
|
||||||
|
environmentId: EnvironmentId;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function createKubernetesStackFromGit({
|
||||||
|
environmentId,
|
||||||
|
...payload
|
||||||
|
}: KubernetesGitRepositoryPayload) {
|
||||||
|
try {
|
||||||
|
const { data } = await axios.post<Stack>(
|
||||||
|
buildCreateUrl('kubernetes', 'repository'),
|
||||||
|
payload,
|
||||||
|
{
|
||||||
|
params: { endpointId: environmentId },
|
||||||
|
}
|
||||||
|
);
|
||||||
|
return data;
|
||||||
|
} catch (e) {
|
||||||
|
throw parseAxiosError(e as Error);
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,32 @@
|
||||||
|
import axios, { parseAxiosError } from '@/portainer/services/axios';
|
||||||
|
import { EnvironmentId } from '@/react/portainer/environments/types';
|
||||||
|
|
||||||
|
import { Stack } from '../../types';
|
||||||
|
|
||||||
|
import { buildCreateUrl } from './buildUrl';
|
||||||
|
|
||||||
|
export interface KubernetesUrlPayload {
|
||||||
|
stackName: string;
|
||||||
|
composeFormat: boolean;
|
||||||
|
namespace: string;
|
||||||
|
manifestURL: string;
|
||||||
|
environmentId: EnvironmentId;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createKubernetesStackFromUrl({
|
||||||
|
environmentId,
|
||||||
|
...payload
|
||||||
|
}: KubernetesUrlPayload) {
|
||||||
|
try {
|
||||||
|
const { data } = await axios.post<Stack>(
|
||||||
|
buildCreateUrl('kubernetes', 'url'),
|
||||||
|
payload,
|
||||||
|
{
|
||||||
|
params: { endpointId: environmentId },
|
||||||
|
}
|
||||||
|
);
|
||||||
|
return data;
|
||||||
|
} catch (e) {
|
||||||
|
throw parseAxiosError(e as Error);
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,44 @@
|
||||||
|
import axios, {
|
||||||
|
json2formData,
|
||||||
|
parseAxiosError,
|
||||||
|
} from '@/portainer/services/axios';
|
||||||
|
import { Pair } from '@/react/portainer/settings/types';
|
||||||
|
import { EnvironmentId } from '@/react/portainer/environments/types';
|
||||||
|
|
||||||
|
import { Stack } from '../../types';
|
||||||
|
|
||||||
|
import { buildCreateUrl } from './buildUrl';
|
||||||
|
|
||||||
|
export type StandaloneFileUploadPayload = {
|
||||||
|
/** Name of the stack */
|
||||||
|
Name: string;
|
||||||
|
|
||||||
|
file: File;
|
||||||
|
/** List of environment variables */
|
||||||
|
Env?: Array<Pair>;
|
||||||
|
|
||||||
|
/** A UUID to identify a webhook. The stack will be force updated and pull the latest image when the webhook was invoked. */
|
||||||
|
Webhook?: string;
|
||||||
|
environmentId: EnvironmentId;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function createStandaloneStackFromFile({
|
||||||
|
environmentId,
|
||||||
|
...payload
|
||||||
|
}: StandaloneFileUploadPayload) {
|
||||||
|
try {
|
||||||
|
const { data } = await axios.post<Stack>(
|
||||||
|
buildCreateUrl('standalone', 'file'),
|
||||||
|
json2formData(payload),
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'multipart/form-data',
|
||||||
|
},
|
||||||
|
params: { endpointId: environmentId },
|
||||||
|
}
|
||||||
|
);
|
||||||
|
return data;
|
||||||
|
} catch (e) {
|
||||||
|
throw parseAxiosError(e as Error);
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,40 @@
|
||||||
|
import axios, { parseAxiosError } from '@/portainer/services/axios';
|
||||||
|
import { Pair } from '@/react/portainer/settings/types';
|
||||||
|
import { EnvironmentId } from '@/react/portainer/environments/types';
|
||||||
|
|
||||||
|
import { Stack } from '../../types';
|
||||||
|
|
||||||
|
import { buildCreateUrl } from './buildUrl';
|
||||||
|
|
||||||
|
export interface StandaloneFileContentPayload {
|
||||||
|
/** Name of the stack */
|
||||||
|
name: string;
|
||||||
|
|
||||||
|
stackFileContent: string;
|
||||||
|
/** List of environment variables */
|
||||||
|
env?: Array<Pair>;
|
||||||
|
|
||||||
|
/** Whether the stack is from an app template */
|
||||||
|
fromAppTemplate?: boolean;
|
||||||
|
/** A UUID to identify a webhook. The stack will be force updated and pull the latest image when the webhook was invoked. */
|
||||||
|
webhook?: string;
|
||||||
|
environmentId: EnvironmentId;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createStandaloneStackFromFileContent({
|
||||||
|
environmentId,
|
||||||
|
...payload
|
||||||
|
}: StandaloneFileContentPayload) {
|
||||||
|
try {
|
||||||
|
const { data } = await axios.post<Stack>(
|
||||||
|
buildCreateUrl('standalone', 'string'),
|
||||||
|
payload,
|
||||||
|
{
|
||||||
|
params: { endpointId: environmentId },
|
||||||
|
}
|
||||||
|
);
|
||||||
|
return data;
|
||||||
|
} catch (e) {
|
||||||
|
throw parseAxiosError(e as Error);
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,63 @@
|
||||||
|
import axios, { parseAxiosError } from '@/portainer/services/axios';
|
||||||
|
import { Pair } from '@/react/portainer/settings/types';
|
||||||
|
import { AutoUpdateModel } from '@/react/portainer/gitops/types';
|
||||||
|
import { EnvironmentId } from '@/react/portainer/environments/types';
|
||||||
|
|
||||||
|
import { Stack } from '../../types';
|
||||||
|
|
||||||
|
import { buildCreateUrl } from './buildUrl';
|
||||||
|
|
||||||
|
export type StandaloneGitRepositoryPayload = {
|
||||||
|
/** Name of the stack */
|
||||||
|
name: string;
|
||||||
|
/** List of environment variables */
|
||||||
|
env?: Array<Pair>;
|
||||||
|
/** Whether the stack is from an app template */
|
||||||
|
fromAppTemplate?: boolean;
|
||||||
|
|
||||||
|
/** URL of a Git repository hosting the Stack file */
|
||||||
|
repositoryUrl: string;
|
||||||
|
/** Reference name of a Git repository hosting the Stack file */
|
||||||
|
repositoryReferenceName?: string;
|
||||||
|
/** Use basic authentication to clone the Git repository */
|
||||||
|
repositoryAuthentication?: boolean;
|
||||||
|
/** Username used in basic authentication. Required when RepositoryAuthentication is true. */
|
||||||
|
repositoryUsername?: string;
|
||||||
|
/** Password used in basic authentication. Required when RepositoryAuthentication is true. */
|
||||||
|
repositoryPassword?: string;
|
||||||
|
/** GitCredentialID used to identify the binded git credential */
|
||||||
|
repositoryGitCredentialId?: number;
|
||||||
|
/** Path to the Stack file inside the Git repository */
|
||||||
|
composeFile?: string;
|
||||||
|
|
||||||
|
additionalFiles?: Array<string>;
|
||||||
|
|
||||||
|
/** Optional GitOps update configuration */
|
||||||
|
autoUpdate?: AutoUpdateModel;
|
||||||
|
|
||||||
|
/** Whether the stack supports relative path volume */
|
||||||
|
supportRelativePath?: boolean;
|
||||||
|
/** Local filesystem path */
|
||||||
|
filesystemPath?: string;
|
||||||
|
/** TLSSkipVerify skips SSL verification when cloning the Git repository */
|
||||||
|
tlsSkipVerify?: boolean;
|
||||||
|
environmentId: EnvironmentId;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function createStandaloneStackFromGit({
|
||||||
|
environmentId,
|
||||||
|
...payload
|
||||||
|
}: StandaloneGitRepositoryPayload) {
|
||||||
|
try {
|
||||||
|
const { data } = await axios.post<Stack>(
|
||||||
|
buildCreateUrl('standalone', 'repository'),
|
||||||
|
payload,
|
||||||
|
{
|
||||||
|
params: { endpointId: environmentId },
|
||||||
|
}
|
||||||
|
);
|
||||||
|
return data;
|
||||||
|
} catch (e) {
|
||||||
|
throw parseAxiosError(e as Error);
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,50 @@
|
||||||
|
import axios, {
|
||||||
|
json2formData,
|
||||||
|
parseAxiosError,
|
||||||
|
} from '@/portainer/services/axios';
|
||||||
|
import { Pair } from '@/react/portainer/settings/types';
|
||||||
|
import { EnvironmentId } from '@/react/portainer/environments/types';
|
||||||
|
|
||||||
|
import { Stack } from '../../types';
|
||||||
|
|
||||||
|
import { buildCreateUrl } from './buildUrl';
|
||||||
|
|
||||||
|
export type SwarmFileUploadPayload = {
|
||||||
|
/** Name of the stack */
|
||||||
|
Name: string;
|
||||||
|
|
||||||
|
/** List of environment variables */
|
||||||
|
Env?: Array<Pair>;
|
||||||
|
|
||||||
|
/** A UUID to identify a webhook. The stack will be force updated and pull the latest image when the webhook was invoked. */
|
||||||
|
Webhook?: string;
|
||||||
|
|
||||||
|
/** Swarm cluster identifier */
|
||||||
|
SwarmID: string;
|
||||||
|
|
||||||
|
file: File;
|
||||||
|
environmentId: EnvironmentId;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function createSwarmStackFromFile({
|
||||||
|
environmentId,
|
||||||
|
...payload
|
||||||
|
}: SwarmFileUploadPayload) {
|
||||||
|
try {
|
||||||
|
const { data } = await axios.post<Stack>(
|
||||||
|
buildCreateUrl('swarm', 'file'),
|
||||||
|
json2formData(payload),
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'multipart/form-data',
|
||||||
|
},
|
||||||
|
params: {
|
||||||
|
endpointId: environmentId,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
return data;
|
||||||
|
} catch (e) {
|
||||||
|
throw parseAxiosError(e as Error);
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,43 @@
|
||||||
|
import axios, { parseAxiosError } from '@/portainer/services/axios';
|
||||||
|
import { Pair } from '@/react/portainer/settings/types';
|
||||||
|
import { EnvironmentId } from '@/react/portainer/environments/types';
|
||||||
|
|
||||||
|
import { Stack } from '../../types';
|
||||||
|
|
||||||
|
import { buildCreateUrl } from './buildUrl';
|
||||||
|
|
||||||
|
export interface SwarmFileContentPayload {
|
||||||
|
/** Name of the stack */
|
||||||
|
name: string;
|
||||||
|
|
||||||
|
stackFileContent: string;
|
||||||
|
/** List of environment variables */
|
||||||
|
env?: Array<Pair>;
|
||||||
|
|
||||||
|
/** Whether the stack is from an app template */
|
||||||
|
fromAppTemplate?: boolean;
|
||||||
|
/** A UUID to identify a webhook. The stack will be force updated and pull the latest image when the webhook was invoked. */
|
||||||
|
webhook?: string;
|
||||||
|
|
||||||
|
/** Swarm cluster identifier */
|
||||||
|
swarmID: string;
|
||||||
|
environmentId: EnvironmentId;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createSwarmStackFromFileContent({
|
||||||
|
environmentId,
|
||||||
|
...payload
|
||||||
|
}: SwarmFileContentPayload) {
|
||||||
|
try {
|
||||||
|
const { data } = await axios.post<Stack>(
|
||||||
|
buildCreateUrl('swarm', 'string'),
|
||||||
|
payload,
|
||||||
|
{
|
||||||
|
params: { endpointId: environmentId },
|
||||||
|
}
|
||||||
|
);
|
||||||
|
return data;
|
||||||
|
} catch (e) {
|
||||||
|
throw parseAxiosError(e as Error);
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,65 @@
|
||||||
|
import axios, { parseAxiosError } from '@/portainer/services/axios';
|
||||||
|
import { Pair } from '@/react/portainer/settings/types';
|
||||||
|
import { AutoUpdateModel } from '@/react/portainer/gitops/types';
|
||||||
|
import { EnvironmentId } from '@/react/portainer/environments/types';
|
||||||
|
|
||||||
|
import { Stack } from '../../types';
|
||||||
|
|
||||||
|
import { buildCreateUrl } from './buildUrl';
|
||||||
|
|
||||||
|
export type SwarmGitRepositoryPayload = {
|
||||||
|
/** Name of the stack */
|
||||||
|
name: string;
|
||||||
|
/** List of environment variables */
|
||||||
|
env?: Array<Pair>;
|
||||||
|
/** Whether the stack is from an app template */
|
||||||
|
fromAppTemplate?: boolean;
|
||||||
|
/** Swarm cluster identifier */
|
||||||
|
swarmID: string;
|
||||||
|
|
||||||
|
/** URL of a Git repository hosting the Stack file */
|
||||||
|
repositoryUrl: string;
|
||||||
|
/** Reference name of a Git repository hosting the Stack file */
|
||||||
|
repositoryReferenceName?: string;
|
||||||
|
/** Use basic authentication to clone the Git repository */
|
||||||
|
repositoryAuthentication?: boolean;
|
||||||
|
/** Username used in basic authentication. Required when RepositoryAuthentication is true. */
|
||||||
|
repositoryUsername?: string;
|
||||||
|
/** Password used in basic authentication. Required when RepositoryAuthentication is true. */
|
||||||
|
repositoryPassword?: string;
|
||||||
|
/** GitCredentialID used to identify the binded git credential */
|
||||||
|
repositoryGitCredentialId?: number;
|
||||||
|
/** Path to the Stack file inside the Git repository */
|
||||||
|
composeFile?: string;
|
||||||
|
|
||||||
|
additionalFiles?: Array<string>;
|
||||||
|
|
||||||
|
/** Optional GitOps update configuration */
|
||||||
|
autoUpdate?: AutoUpdateModel;
|
||||||
|
|
||||||
|
/** Whether the stack supports relative path volume */
|
||||||
|
supportRelativePath?: boolean;
|
||||||
|
/** Local filesystem path */
|
||||||
|
filesystemPath?: string;
|
||||||
|
/** TLSSkipVerify skips SSL verification when cloning the Git repository */
|
||||||
|
tlsSkipVerify?: boolean;
|
||||||
|
environmentId: EnvironmentId;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function createSwarmStackFromGit({
|
||||||
|
environmentId,
|
||||||
|
...payload
|
||||||
|
}: SwarmGitRepositoryPayload) {
|
||||||
|
try {
|
||||||
|
const { data } = await axios.post<Stack>(
|
||||||
|
buildCreateUrl('standalone', 'repository'),
|
||||||
|
payload,
|
||||||
|
{
|
||||||
|
params: { endpointId: environmentId },
|
||||||
|
}
|
||||||
|
);
|
||||||
|
return data;
|
||||||
|
} catch (e) {
|
||||||
|
throw parseAxiosError(e as Error);
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,302 @@
|
||||||
|
import { useMutation, useQueryClient } from 'react-query';
|
||||||
|
|
||||||
|
import { EnvironmentId } from '@/react/portainer/environments/types';
|
||||||
|
import { Pair } from '@/react/portainer/settings/types';
|
||||||
|
import {
|
||||||
|
GitFormModel,
|
||||||
|
RelativePathModel,
|
||||||
|
} from '@/react/portainer/gitops/types';
|
||||||
|
import { applyResourceControl } from '@/react/portainer/access-control/access-control.service';
|
||||||
|
import { AccessControlFormData } from '@/react/portainer/access-control/types';
|
||||||
|
import PortainerError from '@/portainer/error';
|
||||||
|
import { withError, withInvalidate } from '@/react-tools/react-query';
|
||||||
|
|
||||||
|
import { queryKeys } from '../query-keys';
|
||||||
|
|
||||||
|
import { createSwarmStackFromFile } from './createSwarmStackFromFile';
|
||||||
|
import { createSwarmStackFromGit } from './createSwarmStackFromGit';
|
||||||
|
import { createSwarmStackFromFileContent } from './createSwarmStackFromFileContent';
|
||||||
|
import { createStandaloneStackFromFile } from './createStandaloneStackFromFile';
|
||||||
|
import { createStandaloneStackFromGit } from './createStandaloneStackFromGit';
|
||||||
|
import { createStandaloneStackFromFileContent } from './createStandaloneStackFromFileContent';
|
||||||
|
import { createKubernetesStackFromUrl } from './createKubernetesStackFromUrl';
|
||||||
|
import { createKubernetesStackFromGit } from './createKubernetesStackFromGit';
|
||||||
|
import { createKubernetesStackFromFileContent } from './createKubernetesStackFromFileContent';
|
||||||
|
|
||||||
|
export function useCreateStack() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
return useMutation(createStack, {
|
||||||
|
...withError('Failed to create stack'),
|
||||||
|
...withInvalidate(queryClient, [queryKeys.base()]),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
type BasePayload = {
|
||||||
|
name: string;
|
||||||
|
environmentId: EnvironmentId;
|
||||||
|
};
|
||||||
|
|
||||||
|
type DockerBasePayload = BasePayload & {
|
||||||
|
env?: Array<Pair>;
|
||||||
|
accessControl: AccessControlFormData;
|
||||||
|
};
|
||||||
|
|
||||||
|
type SwarmBasePayload = DockerBasePayload & {
|
||||||
|
swarmId: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type KubernetesBasePayload = BasePayload & {
|
||||||
|
namespace: string;
|
||||||
|
composeFormat: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type SwarmCreatePayload =
|
||||||
|
| {
|
||||||
|
method: 'file';
|
||||||
|
payload: SwarmBasePayload & {
|
||||||
|
/** File to upload */
|
||||||
|
file: File;
|
||||||
|
/** Optional webhook configuration */
|
||||||
|
webhook?: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
method: 'string';
|
||||||
|
payload: SwarmBasePayload & {
|
||||||
|
/** Content of the Stack file */
|
||||||
|
fileContent: string;
|
||||||
|
/** Optional webhook configuration */
|
||||||
|
webhook?: string;
|
||||||
|
fromAppTemplate?: boolean;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
method: 'git';
|
||||||
|
payload: SwarmBasePayload & {
|
||||||
|
git: GitFormModel;
|
||||||
|
relativePathSettings?: RelativePathModel;
|
||||||
|
fromAppTemplate?: boolean;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
type StandaloneCreatePayload =
|
||||||
|
| {
|
||||||
|
method: 'file';
|
||||||
|
payload: DockerBasePayload & {
|
||||||
|
/** File to upload */
|
||||||
|
file: File;
|
||||||
|
/** Optional webhook configuration */
|
||||||
|
webhook?: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
method: 'string';
|
||||||
|
payload: DockerBasePayload & {
|
||||||
|
/** Content of the Stack file */
|
||||||
|
fileContent: string;
|
||||||
|
/** Optional webhook configuration */
|
||||||
|
webhook?: string;
|
||||||
|
fromAppTemplate?: boolean;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
method: 'git';
|
||||||
|
payload: DockerBasePayload & {
|
||||||
|
git: GitFormModel;
|
||||||
|
relativePathSettings?: RelativePathModel;
|
||||||
|
fromAppTemplate?: boolean;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
type KubernetesCreatePayload =
|
||||||
|
| {
|
||||||
|
method: 'string';
|
||||||
|
payload: KubernetesBasePayload & {
|
||||||
|
/** Content of the Stack file */
|
||||||
|
fileContent: string;
|
||||||
|
/** Optional webhook configuration */
|
||||||
|
webhook?: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
method: 'git';
|
||||||
|
payload: KubernetesBasePayload & {
|
||||||
|
git: GitFormModel;
|
||||||
|
relativePathSettings?: RelativePathModel;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
method: 'url';
|
||||||
|
payload: KubernetesBasePayload & {
|
||||||
|
manifestUrl: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export type CreateStackPayload =
|
||||||
|
| ({ type: 'swarm' } & SwarmCreatePayload)
|
||||||
|
| ({ type: 'standalone' } & StandaloneCreatePayload)
|
||||||
|
| ({ type: 'kubernetes' } & KubernetesCreatePayload);
|
||||||
|
|
||||||
|
async function createStack(payload: CreateStackPayload) {
|
||||||
|
const stack = await createActualStack(payload);
|
||||||
|
|
||||||
|
if (payload.type === 'standalone' || payload.type === 'swarm') {
|
||||||
|
const resourceControl = stack.ResourceControl;
|
||||||
|
|
||||||
|
// Portainer will always return a resource control, but since types mark it as optional, we need to check it.
|
||||||
|
// Ignoring the missing value will result with bugs, hence it's better to throw an error
|
||||||
|
if (!resourceControl) {
|
||||||
|
throw new PortainerError('resource control expected after creation');
|
||||||
|
}
|
||||||
|
|
||||||
|
await applyResourceControl(
|
||||||
|
payload.payload.accessControl,
|
||||||
|
resourceControl.Id
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function createActualStack(payload: CreateStackPayload) {
|
||||||
|
switch (payload.type) {
|
||||||
|
case 'swarm':
|
||||||
|
return createSwarmStack(payload);
|
||||||
|
case 'standalone':
|
||||||
|
return createStandaloneStack(payload);
|
||||||
|
case 'kubernetes':
|
||||||
|
return createKubernetesStack(payload);
|
||||||
|
default:
|
||||||
|
throw new Error('Invalid type');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function createSwarmStack({ method, payload }: SwarmCreatePayload) {
|
||||||
|
switch (method) {
|
||||||
|
case 'file':
|
||||||
|
return createSwarmStackFromFile({
|
||||||
|
environmentId: payload.environmentId,
|
||||||
|
file: payload.file,
|
||||||
|
Name: payload.name,
|
||||||
|
SwarmID: payload.swarmId,
|
||||||
|
Env: payload.env,
|
||||||
|
Webhook: payload.webhook,
|
||||||
|
});
|
||||||
|
case 'git':
|
||||||
|
return createSwarmStackFromGit({
|
||||||
|
name: payload.name,
|
||||||
|
env: payload.env,
|
||||||
|
repositoryUrl: payload.git.RepositoryURL,
|
||||||
|
repositoryReferenceName: payload.git.RepositoryReferenceName,
|
||||||
|
composeFile: payload.git.ComposeFilePathInRepository,
|
||||||
|
repositoryAuthentication: payload.git.RepositoryAuthentication,
|
||||||
|
repositoryUsername: payload.git.RepositoryUsername,
|
||||||
|
repositoryPassword: payload.git.RepositoryPassword,
|
||||||
|
repositoryGitCredentialId: payload.git.RepositoryGitCredentialID,
|
||||||
|
filesystemPath: payload.relativePathSettings?.FilesystemPath,
|
||||||
|
supportRelativePath: payload.relativePathSettings?.SupportRelativePath,
|
||||||
|
tlsSkipVerify: payload.git.TLSSkipVerify,
|
||||||
|
autoUpdate: payload.git.AutoUpdate,
|
||||||
|
environmentId: payload.environmentId,
|
||||||
|
swarmID: payload.swarmId,
|
||||||
|
additionalFiles: payload.git.AdditionalFiles,
|
||||||
|
fromAppTemplate: payload.fromAppTemplate,
|
||||||
|
});
|
||||||
|
case 'string':
|
||||||
|
return createSwarmStackFromFileContent({
|
||||||
|
name: payload.name,
|
||||||
|
env: payload.env,
|
||||||
|
environmentId: payload.environmentId,
|
||||||
|
stackFileContent: payload.fileContent,
|
||||||
|
webhook: payload.webhook,
|
||||||
|
swarmID: payload.swarmId,
|
||||||
|
fromAppTemplate: payload.fromAppTemplate,
|
||||||
|
});
|
||||||
|
default:
|
||||||
|
throw new Error('Invalid method');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function createStandaloneStack({ method, payload }: StandaloneCreatePayload) {
|
||||||
|
switch (method) {
|
||||||
|
case 'file':
|
||||||
|
return createStandaloneStackFromFile({
|
||||||
|
environmentId: payload.environmentId,
|
||||||
|
file: payload.file,
|
||||||
|
Name: payload.name,
|
||||||
|
Env: payload.env,
|
||||||
|
Webhook: payload.webhook,
|
||||||
|
});
|
||||||
|
case 'git':
|
||||||
|
return createStandaloneStackFromGit({
|
||||||
|
name: payload.name,
|
||||||
|
env: payload.env,
|
||||||
|
repositoryUrl: payload.git.RepositoryURL,
|
||||||
|
repositoryReferenceName: payload.git.RepositoryReferenceName,
|
||||||
|
composeFile: payload.git.ComposeFilePathInRepository,
|
||||||
|
repositoryAuthentication: payload.git.RepositoryAuthentication,
|
||||||
|
repositoryUsername: payload.git.RepositoryUsername,
|
||||||
|
repositoryPassword: payload.git.RepositoryPassword,
|
||||||
|
repositoryGitCredentialId: payload.git.RepositoryGitCredentialID,
|
||||||
|
filesystemPath: payload.relativePathSettings?.FilesystemPath,
|
||||||
|
supportRelativePath: payload.relativePathSettings?.SupportRelativePath,
|
||||||
|
tlsSkipVerify: payload.git.TLSSkipVerify,
|
||||||
|
autoUpdate: payload.git.AutoUpdate,
|
||||||
|
environmentId: payload.environmentId,
|
||||||
|
additionalFiles: payload.git.AdditionalFiles,
|
||||||
|
fromAppTemplate: payload.fromAppTemplate,
|
||||||
|
});
|
||||||
|
case 'string':
|
||||||
|
return createStandaloneStackFromFileContent({
|
||||||
|
name: payload.name,
|
||||||
|
env: payload.env,
|
||||||
|
environmentId: payload.environmentId,
|
||||||
|
stackFileContent: payload.fileContent,
|
||||||
|
webhook: payload.webhook,
|
||||||
|
fromAppTemplate: payload.fromAppTemplate,
|
||||||
|
});
|
||||||
|
default:
|
||||||
|
throw new Error('Invalid method');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function createKubernetesStack({ method, payload }: KubernetesCreatePayload) {
|
||||||
|
switch (method) {
|
||||||
|
case 'string':
|
||||||
|
return createKubernetesStackFromFileContent({
|
||||||
|
stackName: payload.name,
|
||||||
|
|
||||||
|
environmentId: payload.environmentId,
|
||||||
|
stackFileContent: payload.fileContent,
|
||||||
|
composeFormat: payload.composeFormat,
|
||||||
|
namespace: payload.namespace,
|
||||||
|
});
|
||||||
|
case 'git':
|
||||||
|
return createKubernetesStackFromGit({
|
||||||
|
stackName: payload.name,
|
||||||
|
|
||||||
|
repositoryUrl: payload.git.RepositoryURL,
|
||||||
|
repositoryReferenceName: payload.git.RepositoryReferenceName,
|
||||||
|
manifestFile: payload.git.ComposeFilePathInRepository,
|
||||||
|
repositoryAuthentication: payload.git.RepositoryAuthentication,
|
||||||
|
repositoryUsername: payload.git.RepositoryUsername,
|
||||||
|
repositoryPassword: payload.git.RepositoryPassword,
|
||||||
|
repositoryGitCredentialId: payload.git.RepositoryGitCredentialID,
|
||||||
|
|
||||||
|
tlsSkipVerify: payload.git.TLSSkipVerify,
|
||||||
|
autoUpdate: payload.git.AutoUpdate,
|
||||||
|
environmentId: payload.environmentId,
|
||||||
|
additionalFiles: payload.git.AdditionalFiles,
|
||||||
|
composeFormat: payload.composeFormat,
|
||||||
|
namespace: payload.namespace,
|
||||||
|
});
|
||||||
|
case 'url':
|
||||||
|
return createKubernetesStackFromUrl({
|
||||||
|
stackName: payload.name,
|
||||||
|
composeFormat: payload.composeFormat,
|
||||||
|
environmentId: payload.environmentId,
|
||||||
|
manifestURL: payload.manifestUrl,
|
||||||
|
namespace: payload.namespace,
|
||||||
|
});
|
||||||
|
default:
|
||||||
|
throw new Error('Invalid method');
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,23 @@
|
||||||
|
import { useQuery } from 'react-query';
|
||||||
|
|
||||||
|
import { withError } from '@/react-tools/react-query';
|
||||||
|
import axios, { parseAxiosError } from '@/portainer/services/axios';
|
||||||
|
import { Stack } from '@/react/common/stacks/types';
|
||||||
|
|
||||||
|
import { buildStackUrl } from './buildUrl';
|
||||||
|
import { queryKeys } from './query-keys';
|
||||||
|
|
||||||
|
export function useStacks() {
|
||||||
|
return useQuery(queryKeys.base(), () => getStacks(), {
|
||||||
|
...withError('Failed loading stack'),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getStacks() {
|
||||||
|
try {
|
||||||
|
const { data } = await axios.get<Stack[]>(buildStackUrl());
|
||||||
|
return data;
|
||||||
|
} catch (e) {
|
||||||
|
throw parseAxiosError(e as Error);
|
||||||
|
}
|
||||||
|
}
|
|
@ -30,8 +30,8 @@ export interface Stack {
|
||||||
Id: number;
|
Id: number;
|
||||||
Name: string;
|
Name: string;
|
||||||
Type: StackType;
|
Type: StackType;
|
||||||
EndpointID: number;
|
EndpointId: number;
|
||||||
SwarmID: string;
|
SwarmId: string;
|
||||||
EntryPoint: string;
|
EntryPoint: string;
|
||||||
Env: {
|
Env: {
|
||||||
name: string;
|
name: string;
|
||||||
|
|
|
@ -11,9 +11,7 @@ import { useEnvironmentId } from '@/react/hooks/useEnvironmentId';
|
||||||
import { isAgentEnvironment } from '@/react/portainer/environments/utils';
|
import { isAgentEnvironment } from '@/react/portainer/environments/utils';
|
||||||
import { FeatureId } from '@/react/portainer/feature-flags/enums';
|
import { FeatureId } from '@/react/portainer/feature-flags/enums';
|
||||||
|
|
||||||
import { FormControl } from '@@/form-components/FormControl';
|
|
||||||
import { FormSection } from '@@/form-components/FormSection';
|
import { FormSection } from '@@/form-components/FormSection';
|
||||||
import { Input } from '@@/form-components/Input';
|
|
||||||
import { SwitchField } from '@@/form-components/SwitchField';
|
import { SwitchField } from '@@/form-components/SwitchField';
|
||||||
import { ImageConfigFieldset, ImageConfigValues } from '@@/ImageConfigFieldset';
|
import { ImageConfigFieldset, ImageConfigValues } from '@@/ImageConfigFieldset';
|
||||||
import { LoadingButton } from '@@/buttons';
|
import { LoadingButton } from '@@/buttons';
|
||||||
|
@ -23,6 +21,7 @@ import {
|
||||||
PortsMappingField,
|
PortsMappingField,
|
||||||
Values as PortMappingValue,
|
Values as PortMappingValue,
|
||||||
} from './PortsMappingField';
|
} from './PortsMappingField';
|
||||||
|
import { NameField } from './NameField';
|
||||||
|
|
||||||
export interface Values {
|
export interface Values {
|
||||||
name: string;
|
name: string;
|
||||||
|
@ -74,19 +73,14 @@ export function BaseForm({
|
||||||
return (
|
return (
|
||||||
<Widget>
|
<Widget>
|
||||||
<Widget.Body>
|
<Widget.Body>
|
||||||
<FormControl label="Name" inputId="name-input" errors={errors?.name}>
|
<NameField
|
||||||
<Input
|
value={values.name}
|
||||||
id="name-input"
|
onChange={(name) => {
|
||||||
value={values.name}
|
setFieldValue('name', name);
|
||||||
onChange={(e) => {
|
onChangeName(name);
|
||||||
const name = e.target.value;
|
}}
|
||||||
onChangeName(name);
|
error={errors?.name}
|
||||||
setFieldValue('name', name);
|
/>
|
||||||
}}
|
|
||||||
placeholder="e.g. myContainer"
|
|
||||||
data-cy="container-name-input"
|
|
||||||
/>
|
|
||||||
</FormControl>
|
|
||||||
|
|
||||||
<FormSection title="Image Configuration">
|
<FormSection title="Image Configuration">
|
||||||
<ImageConfigFieldset
|
<ImageConfigFieldset
|
||||||
|
|
|
@ -0,0 +1,32 @@
|
||||||
|
import { string } from 'yup';
|
||||||
|
|
||||||
|
import { FormControl } from '@@/form-components/FormControl';
|
||||||
|
import { Input } from '@@/form-components/Input';
|
||||||
|
|
||||||
|
export function NameField({
|
||||||
|
value,
|
||||||
|
error,
|
||||||
|
onChange,
|
||||||
|
}: {
|
||||||
|
value: string;
|
||||||
|
error?: string;
|
||||||
|
onChange: (value: string) => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<FormControl label="Name" inputId="name-input" errors={error}>
|
||||||
|
<Input
|
||||||
|
id="name-input"
|
||||||
|
value={value}
|
||||||
|
onChange={(e) => {
|
||||||
|
onChange(e.target.value);
|
||||||
|
}}
|
||||||
|
placeholder="e.g. myContainer"
|
||||||
|
data-cy="container-name-input"
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function nameValidation() {
|
||||||
|
return string().default('');
|
||||||
|
}
|
|
@ -9,7 +9,7 @@ type PortKey = `${string}/${Protocol}`;
|
||||||
export function parsePortBindingRequest(portBindings: Values): PortMap {
|
export function parsePortBindingRequest(portBindings: Values): PortMap {
|
||||||
const bindings: Record<
|
const bindings: Record<
|
||||||
PortKey,
|
PortKey,
|
||||||
Array<{ HostIp: string; HostPort: string }>
|
Array<{ HostIp?: string; HostPort?: string }>
|
||||||
> = {};
|
> = {};
|
||||||
_.forEach(portBindings, (portBinding) => {
|
_.forEach(portBindings, (portBinding) => {
|
||||||
if (!portBinding.containerPort) {
|
if (!portBinding.containerPort) {
|
||||||
|
@ -17,9 +17,6 @@ export function parsePortBindingRequest(portBindings: Values): PortMap {
|
||||||
}
|
}
|
||||||
|
|
||||||
const portInfo = extractPortInfo(portBinding);
|
const portInfo = extractPortInfo(portBinding);
|
||||||
if (!portInfo) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
let { hostPort } = portBinding;
|
let { hostPort } = portBinding;
|
||||||
const { endHostPort, endPort, hostIp, startHostPort, startPort } = portInfo;
|
const { endHostPort, endPort, hostIp, startHostPort, startPort } = portInfo;
|
||||||
|
@ -36,7 +33,9 @@ export function parsePortBindingRequest(portBindings: Values): PortMap {
|
||||||
hostPort += `-${endHostPort.toString()}`;
|
hostPort += `-${endHostPort.toString()}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
bindings[bindKey].push({ HostIp: hostIp, HostPort: hostPort });
|
bindings[bindKey].push(
|
||||||
|
hostIp || hostPort ? { HostIp: hostIp, HostPort: hostPort } : {}
|
||||||
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
return bindings;
|
return bindings;
|
||||||
|
@ -71,7 +70,13 @@ function parsePort(port: string) {
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
function extractPortInfo(portBinding: PortMapping) {
|
function extractPortInfo(portBinding: PortMapping): {
|
||||||
|
startPort: number;
|
||||||
|
endPort: number;
|
||||||
|
hostIp: string;
|
||||||
|
startHostPort: number;
|
||||||
|
endHostPort: number;
|
||||||
|
} {
|
||||||
const containerPortRange = parsePortRange(portBinding.containerPort);
|
const containerPortRange = parsePortRange(portBinding.containerPort);
|
||||||
if (!isValidPortRange(containerPortRange)) {
|
if (!isValidPortRange(containerPortRange)) {
|
||||||
throw new Error(`Invalid port specification: ${portBinding.containerPort}`);
|
throw new Error(`Invalid port specification: ${portBinding.containerPort}`);
|
||||||
|
@ -82,7 +87,13 @@ function extractPortInfo(portBinding: PortMapping) {
|
||||||
let hostIp = '';
|
let hostIp = '';
|
||||||
let { hostPort } = portBinding;
|
let { hostPort } = portBinding;
|
||||||
if (!hostPort) {
|
if (!hostPort) {
|
||||||
return null;
|
return {
|
||||||
|
startPort,
|
||||||
|
endPort,
|
||||||
|
hostIp: '',
|
||||||
|
startHostPort: 0,
|
||||||
|
endHostPort: 0,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
if (hostPort.includes('[')) {
|
if (hostPort.includes('[')) {
|
||||||
|
|
|
@ -5,7 +5,7 @@ import { Values } from './PortsMappingField';
|
||||||
export function validationSchema(): SchemaOf<Values> {
|
export function validationSchema(): SchemaOf<Values> {
|
||||||
return array(
|
return array(
|
||||||
object({
|
object({
|
||||||
hostPort: string().required('host is required'),
|
hostPort: string().default(''),
|
||||||
containerPort: string().required('container is required'),
|
containerPort: string().required('container is required'),
|
||||||
protocol: mixed().oneOf(['tcp', 'udp']),
|
protocol: mixed().oneOf(['tcp', 'udp']),
|
||||||
})
|
})
|
||||||
|
|
|
@ -6,6 +6,7 @@ import { imageConfigValidation } from '@@/ImageConfigFieldset';
|
||||||
|
|
||||||
import { Values } from './BaseForm';
|
import { Values } from './BaseForm';
|
||||||
import { validationSchema as portsSchema } from './PortsMappingField.validation';
|
import { validationSchema as portsSchema } from './PortsMappingField.validation';
|
||||||
|
import { nameValidation } from './NameField';
|
||||||
|
|
||||||
export function validation(
|
export function validation(
|
||||||
{
|
{
|
||||||
|
@ -26,9 +27,10 @@ export function validation(
|
||||||
}
|
}
|
||||||
): SchemaOf<Values> {
|
): SchemaOf<Values> {
|
||||||
return object({
|
return object({
|
||||||
name: string()
|
name: nameValidation().test(
|
||||||
.default('')
|
'not-duplicate-portainer',
|
||||||
.test('not-duplicate-portainer', () => !isDuplicatingPortainer),
|
() => !isDuplicatingPortainer
|
||||||
|
),
|
||||||
alwaysPull: boolean()
|
alwaysPull: boolean()
|
||||||
.default(true)
|
.default(true)
|
||||||
.test('rate-limits', 'Rate limit exceeded', (alwaysPull: boolean) =>
|
.test('rate-limits', 'Rate limit exceeded', (alwaysPull: boolean) =>
|
||||||
|
|
|
@ -40,30 +40,30 @@ export function toRequest(
|
||||||
}
|
}
|
||||||
|
|
||||||
return config;
|
return config;
|
||||||
|
}
|
||||||
|
|
||||||
function getConsoleConfig(value: ConsoleSetting): ConsoleConfig {
|
export function getConsoleConfig(value: ConsoleSetting): ConsoleConfig {
|
||||||
switch (value) {
|
switch (value) {
|
||||||
case 'both':
|
case 'both':
|
||||||
return { OpenStdin: true, Tty: true };
|
return { OpenStdin: true, Tty: true };
|
||||||
case 'interactive':
|
case 'interactive':
|
||||||
return { OpenStdin: true, Tty: false };
|
return { OpenStdin: true, Tty: false };
|
||||||
case 'tty':
|
case 'tty':
|
||||||
return { OpenStdin: false, Tty: true };
|
return { OpenStdin: false, Tty: true };
|
||||||
case 'none':
|
case 'none':
|
||||||
default:
|
default:
|
||||||
return { OpenStdin: false, Tty: false };
|
return { OpenStdin: false, Tty: false };
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function getLogConfig(
|
|
||||||
value: LogConfig
|
|
||||||
): CreateContainerRequest['HostConfig']['LogConfig'] {
|
|
||||||
return {
|
|
||||||
Type: value.type,
|
|
||||||
Config: Object.fromEntries(
|
|
||||||
value.options.map(({ option, value }) => [option, value])
|
|
||||||
),
|
|
||||||
// docker types - requires union while it should allow also custom string for custom plugins
|
|
||||||
} as CreateContainerRequest['HostConfig']['LogConfig'];
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getLogConfig(
|
||||||
|
value: LogConfig
|
||||||
|
): CreateContainerRequest['HostConfig']['LogConfig'] {
|
||||||
|
return {
|
||||||
|
Type: value.type,
|
||||||
|
Config: Object.fromEntries(
|
||||||
|
value.options.map(({ option, value }) => [option, value])
|
||||||
|
),
|
||||||
|
// docker types - requires union while it should allow also custom string for custom plugins
|
||||||
|
} as CreateContainerRequest['HostConfig']['LogConfig'];
|
||||||
|
}
|
||||||
|
|
|
@ -145,7 +145,21 @@ function CreateForm() {
|
||||||
const config = toRequest(values, registry, hideCapabilities);
|
const config = toRequest(values, registry, hideCapabilities);
|
||||||
|
|
||||||
return mutation.mutate(
|
return mutation.mutate(
|
||||||
{ config, environment, values, registry, oldContainer, extraNetworks },
|
{
|
||||||
|
config,
|
||||||
|
environment,
|
||||||
|
values: {
|
||||||
|
accessControl: values.accessControl,
|
||||||
|
imageName: values.image.image,
|
||||||
|
name: values.name,
|
||||||
|
alwaysPull: values.alwaysPull,
|
||||||
|
enableWebhook: values.enableWebhook,
|
||||||
|
nodeName: values.nodeName,
|
||||||
|
},
|
||||||
|
registry,
|
||||||
|
oldContainer,
|
||||||
|
extraNetworks,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
onSuccess() {
|
onSuccess() {
|
||||||
sendAnalytics(values, registry);
|
sendAnalytics(values, registry);
|
||||||
|
|
|
@ -101,11 +101,6 @@ export function InnerForm({
|
||||||
setFieldValue('volumes', value)
|
setFieldValue('volumes', value)
|
||||||
}
|
}
|
||||||
errors={errors.volumes}
|
errors={errors.volumes}
|
||||||
allowBindMounts={
|
|
||||||
isEnvironmentAdminQuery.authorized ||
|
|
||||||
environment.SecuritySettings
|
|
||||||
.allowBindMountsForRegularUsers
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
|
|
|
@ -0,0 +1,27 @@
|
||||||
|
import { string } from 'yup';
|
||||||
|
|
||||||
|
import { FormControl } from '@@/form-components/FormControl';
|
||||||
|
import { Input } from '@@/form-components/Input';
|
||||||
|
|
||||||
|
export function HostnameField({
|
||||||
|
value,
|
||||||
|
error,
|
||||||
|
onChange,
|
||||||
|
}: {
|
||||||
|
value: string;
|
||||||
|
error?: string;
|
||||||
|
onChange: (value: string) => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<FormControl label="Hostname" errors={error}>
|
||||||
|
<Input
|
||||||
|
value={value}
|
||||||
|
onChange={(e) => onChange(e.target.value)}
|
||||||
|
placeholder="e.g. web01"
|
||||||
|
data-cy="docker-container-hostname-input"
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const hostnameSchema = string().default('');
|
|
@ -0,0 +1,56 @@
|
||||||
|
import { array, string } from 'yup';
|
||||||
|
|
||||||
|
import { FormError } from '@@/form-components/FormError';
|
||||||
|
import { InputLabeled } from '@@/form-components/Input/InputLabeled';
|
||||||
|
import { ItemProps } from '@@/form-components/InputList';
|
||||||
|
import { ArrayError, InputList } from '@@/form-components/InputList/InputList';
|
||||||
|
|
||||||
|
export const hostFileSchema = array(
|
||||||
|
string().required('Entry is required')
|
||||||
|
).default([]);
|
||||||
|
|
||||||
|
export function HostsFileEntries({
|
||||||
|
values,
|
||||||
|
onChange,
|
||||||
|
errors,
|
||||||
|
}: {
|
||||||
|
values: string[];
|
||||||
|
onChange: (values: string[]) => void;
|
||||||
|
errors?: ArrayError<string>;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<InputList
|
||||||
|
label="Hosts file entries"
|
||||||
|
value={values}
|
||||||
|
onChange={(hostsFileEntries) => onChange(hostsFileEntries)}
|
||||||
|
errors={errors}
|
||||||
|
item={HostsFileEntryItem}
|
||||||
|
itemBuilder={() => ''}
|
||||||
|
data-cy="hosts-file-entries"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function HostsFileEntryItem({
|
||||||
|
item,
|
||||||
|
onChange,
|
||||||
|
disabled,
|
||||||
|
error,
|
||||||
|
readOnly,
|
||||||
|
index,
|
||||||
|
}: ItemProps<string>) {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<InputLabeled
|
||||||
|
label="value"
|
||||||
|
value={item}
|
||||||
|
onChange={(e) => onChange(e.target.value)}
|
||||||
|
disabled={disabled}
|
||||||
|
readOnly={readOnly}
|
||||||
|
data-cy={`hosts-file-entry_${index}`}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{error && <FormError>{error}</FormError>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
|
@ -2,14 +2,13 @@ import { FormikErrors } from 'formik';
|
||||||
|
|
||||||
import { FormControl } from '@@/form-components/FormControl';
|
import { FormControl } from '@@/form-components/FormControl';
|
||||||
import { Input } from '@@/form-components/Input';
|
import { Input } from '@@/form-components/Input';
|
||||||
import { InputList, ItemProps } from '@@/form-components/InputList';
|
|
||||||
import { InputGroup } from '@@/form-components/InputGroup';
|
|
||||||
import { FormError } from '@@/form-components/FormError';
|
|
||||||
|
|
||||||
import { NetworkSelector } from '../../components/NetworkSelector';
|
import { NetworkSelector } from '../../components/NetworkSelector';
|
||||||
|
|
||||||
import { CONTAINER_MODE, Values } from './types';
|
import { CONTAINER_MODE, Values } from './types';
|
||||||
import { ContainerSelector } from './ContainerSelector';
|
import { ContainerSelector } from './ContainerSelector';
|
||||||
|
import { HostsFileEntries } from './HostsFileEntries';
|
||||||
|
import { HostnameField } from './HostnameField';
|
||||||
|
|
||||||
export function NetworkTab({
|
export function NetworkTab({
|
||||||
values,
|
values,
|
||||||
|
@ -39,14 +38,10 @@ export function NetworkTab({
|
||||||
</FormControl>
|
</FormControl>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<FormControl label="Hostname" errors={errors?.hostname}>
|
<HostnameField
|
||||||
<Input
|
value={values.hostname}
|
||||||
value={values.hostname}
|
onChange={(value) => setFieldValue('hostname', value)}
|
||||||
onChange={(e) => setFieldValue('hostname', e.target.value)}
|
/>
|
||||||
placeholder="e.g. web01"
|
|
||||||
data-cy="docker-container-hostname-input"
|
|
||||||
/>
|
|
||||||
</FormControl>
|
|
||||||
|
|
||||||
<FormControl label="Domain Name" errors={errors?.domain}>
|
<FormControl label="Domain Name" errors={errors?.domain}>
|
||||||
<Input
|
<Input
|
||||||
|
@ -102,43 +97,11 @@ export function NetworkTab({
|
||||||
/>
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
|
|
||||||
<InputList
|
<HostsFileEntries
|
||||||
label="Hosts file entries"
|
values={values.hostsFileEntries}
|
||||||
value={values.hostsFileEntries}
|
onChange={(v) => setFieldValue('hostsFileEntries', v)}
|
||||||
onChange={(hostsFileEntries) =>
|
|
||||||
setFieldValue('hostsFileEntries', hostsFileEntries)
|
|
||||||
}
|
|
||||||
errors={errors?.hostsFileEntries}
|
errors={errors?.hostsFileEntries}
|
||||||
item={HostsFileEntryItem}
|
|
||||||
itemBuilder={() => ''}
|
|
||||||
data-cy="docker-container-hosts-file-entries"
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function HostsFileEntryItem({
|
|
||||||
item,
|
|
||||||
onChange,
|
|
||||||
disabled,
|
|
||||||
error,
|
|
||||||
readOnly,
|
|
||||||
index,
|
|
||||||
}: ItemProps<string>) {
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<InputGroup>
|
|
||||||
<InputGroup.Addon>value</InputGroup.Addon>
|
|
||||||
<Input
|
|
||||||
value={item}
|
|
||||||
onChange={(e) => onChange(e.target.value)}
|
|
||||||
disabled={disabled}
|
|
||||||
readOnly={readOnly}
|
|
||||||
data-cy={`docker-container-hosts-file-entry_${index}`}
|
|
||||||
/>
|
|
||||||
</InputGroup>
|
|
||||||
|
|
||||||
{error && <FormError>{error}</FormError>}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
|
@ -1,18 +1,20 @@
|
||||||
import { array, object, SchemaOf, string } from 'yup';
|
import { object, SchemaOf, string } from 'yup';
|
||||||
|
|
||||||
import { Values } from './types';
|
import { Values } from './types';
|
||||||
|
import { hostnameSchema } from './HostnameField';
|
||||||
|
import { hostFileSchema } from './HostsFileEntries';
|
||||||
|
|
||||||
export function validation(): SchemaOf<Values> {
|
export function validation(): SchemaOf<Values> {
|
||||||
return object({
|
return object({
|
||||||
networkMode: string().default(''),
|
networkMode: string().default(''),
|
||||||
hostname: string().default(''),
|
hostname: hostnameSchema,
|
||||||
domain: string().default(''),
|
domain: string().default(''),
|
||||||
macAddress: string().default(''),
|
macAddress: string().default(''),
|
||||||
ipv4Address: string().default(''),
|
ipv4Address: string().default(''),
|
||||||
ipv6Address: string().default(''),
|
ipv6Address: string().default(''),
|
||||||
primaryDns: string().default(''),
|
primaryDns: string().default(''),
|
||||||
secondaryDns: string().default(''),
|
secondaryDns: string().default(''),
|
||||||
hostsFileEntries: array(string().required('Entry is required')).default([]),
|
hostsFileEntries: hostFileSchema,
|
||||||
container: string()
|
container: string()
|
||||||
.default('')
|
.default('')
|
||||||
.when('network', {
|
.when('network', {
|
||||||
|
|
|
@ -11,13 +11,15 @@ export function RuntimeSelector({
|
||||||
onChange: (value: string) => void;
|
onChange: (value: string) => void;
|
||||||
}) {
|
}) {
|
||||||
const environmentId = useEnvironmentId();
|
const environmentId = useEnvironmentId();
|
||||||
const infoQuery = useInfo(environmentId, (info) => [
|
const infoQuery = useInfo(environmentId, {
|
||||||
{ label: 'Default', value: '' },
|
select: (info) => [
|
||||||
...Object.keys(info?.Runtimes || {}).map((runtime) => ({
|
{ label: 'Default', value: '' },
|
||||||
label: runtime,
|
...Object.keys(info?.Runtimes || {}).map((runtime) => ({
|
||||||
value: runtime,
|
label: runtime,
|
||||||
})),
|
value: runtime,
|
||||||
]);
|
})),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PortainerSelect
|
<PortainerSelect
|
||||||
|
|
|
@ -18,7 +18,7 @@ export function Item({
|
||||||
error,
|
error,
|
||||||
index,
|
index,
|
||||||
}: ItemProps<Volume>) {
|
}: ItemProps<Volume>) {
|
||||||
const allowBindMounts = useInputContext();
|
const { allowBindMounts, allowAuto } = useInputContext();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
|
@ -61,6 +61,7 @@ export function Item({
|
||||||
value={volume.name}
|
value={volume.name}
|
||||||
onChange={(name) => setValue({ name })}
|
onChange={(name) => setValue({ name })}
|
||||||
inputId={`volume-${index}`}
|
inputId={`volume-${index}`}
|
||||||
|
allowAuto={allowAuto}
|
||||||
/>
|
/>
|
||||||
</InputGroup>
|
</InputGroup>
|
||||||
)}
|
)}
|
||||||
|
|
|
@ -8,10 +8,12 @@ export function VolumeSelector({
|
||||||
value,
|
value,
|
||||||
onChange,
|
onChange,
|
||||||
inputId,
|
inputId,
|
||||||
|
allowAuto,
|
||||||
}: {
|
}: {
|
||||||
value: string;
|
value: string;
|
||||||
onChange: (value?: string) => void;
|
onChange: (value?: string) => void;
|
||||||
inputId?: string;
|
inputId?: string;
|
||||||
|
allowAuto: boolean;
|
||||||
}) {
|
}) {
|
||||||
const environmentId = useEnvironmentId();
|
const environmentId = useEnvironmentId();
|
||||||
const volumesQuery = useVolumes(environmentId, {
|
const volumesQuery = useVolumes(environmentId, {
|
||||||
|
@ -24,7 +26,9 @@ export function VolumeSelector({
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const volumes = volumesQuery.data;
|
const volumes = allowAuto
|
||||||
|
? [...volumesQuery.data, { Name: 'auto', Driver: '' }]
|
||||||
|
: volumesQuery.data;
|
||||||
|
|
||||||
const selectedValue = volumes.find((vol) => vol.Name === value);
|
const selectedValue = volumes.find((vol) => vol.Name === value);
|
||||||
|
|
||||||
|
@ -33,7 +37,9 @@ export function VolumeSelector({
|
||||||
placeholder="Select a volume"
|
placeholder="Select a volume"
|
||||||
options={volumes}
|
options={volumes}
|
||||||
getOptionLabel={(vol) =>
|
getOptionLabel={(vol) =>
|
||||||
`${truncate(vol.Name, 30)} - ${truncate(vol.Driver, 30)}`
|
vol.Name !== 'auto'
|
||||||
|
? `${truncate(vol.Name, 30)} - ${truncate(vol.Driver, 30)}`
|
||||||
|
: 'auto'
|
||||||
}
|
}
|
||||||
getOptionValue={(vol) => vol.Name}
|
getOptionValue={(vol) => vol.Name}
|
||||||
isMulti={false}
|
isMulti={false}
|
||||||
|
|
|
@ -1,3 +1,8 @@
|
||||||
|
import { useMemo } from 'react';
|
||||||
|
|
||||||
|
import { useIsEnvironmentAdmin } from '@/react/hooks/useUser';
|
||||||
|
import { useCurrentEnvironment } from '@/react/hooks/useCurrentEnvironment';
|
||||||
|
|
||||||
import { InputList } from '@@/form-components/InputList';
|
import { InputList } from '@@/form-components/InputList';
|
||||||
import { ArrayError } from '@@/form-components/InputList/InputList';
|
import { ArrayError } from '@@/form-components/InputList/InputList';
|
||||||
|
|
||||||
|
@ -8,16 +13,29 @@ import { Item } from './Item';
|
||||||
export function VolumesTab({
|
export function VolumesTab({
|
||||||
onChange,
|
onChange,
|
||||||
values,
|
values,
|
||||||
allowBindMounts,
|
|
||||||
errors,
|
errors,
|
||||||
|
allowAuto = false,
|
||||||
}: {
|
}: {
|
||||||
onChange: (values: Values) => void;
|
onChange: (values: Values) => void;
|
||||||
values: Values;
|
values: Values;
|
||||||
allowBindMounts: boolean;
|
|
||||||
errors?: ArrayError<Values>;
|
errors?: ArrayError<Values>;
|
||||||
|
allowAuto?: boolean;
|
||||||
}) {
|
}) {
|
||||||
|
const isEnvironmentAdminQuery = useIsEnvironmentAdmin({ adminOnlyCE: true });
|
||||||
|
const envQuery = useCurrentEnvironment();
|
||||||
|
|
||||||
|
const allowBindMounts = !!(
|
||||||
|
isEnvironmentAdminQuery.authorized ||
|
||||||
|
envQuery.data?.SecuritySettings.allowBindMountsForRegularUsers
|
||||||
|
);
|
||||||
|
|
||||||
|
const inputContext = useMemo(
|
||||||
|
() => ({ allowBindMounts, allowAuto }),
|
||||||
|
[allowAuto, allowBindMounts]
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<InputContext.Provider value={allowBindMounts}>
|
<InputContext.Provider value={inputContext}>
|
||||||
<InputList<Volume>
|
<InputList<Volume>
|
||||||
errors={Array.isArray(errors) ? errors : []}
|
errors={Array.isArray(errors) ? errors : []}
|
||||||
label="Volume mapping"
|
label="Volume mapping"
|
||||||
|
|
|
@ -1,6 +1,9 @@
|
||||||
import { createContext, useContext } from 'react';
|
import { createContext, useContext } from 'react';
|
||||||
|
|
||||||
export const InputContext = createContext<boolean | null>(null);
|
export const InputContext = createContext<{
|
||||||
|
allowAuto: boolean;
|
||||||
|
allowBindMounts: boolean;
|
||||||
|
} | null>(null);
|
||||||
|
|
||||||
export function useInputContext() {
|
export function useInputContext() {
|
||||||
const value = useContext(InputContext);
|
const value = useContext(InputContext);
|
||||||
|
|
|
@ -62,7 +62,14 @@ export function useCreateOrReplaceMutation() {
|
||||||
|
|
||||||
interface CreateOptions {
|
interface CreateOptions {
|
||||||
config: CreateContainerRequest;
|
config: CreateContainerRequest;
|
||||||
values: Values;
|
values: {
|
||||||
|
name: Values['name'];
|
||||||
|
imageName: string;
|
||||||
|
accessControl: Values['accessControl'];
|
||||||
|
nodeName?: Values['nodeName'];
|
||||||
|
alwaysPull?: Values['alwaysPull'];
|
||||||
|
enableWebhook?: Values['enableWebhook'];
|
||||||
|
};
|
||||||
registry?: Registry;
|
registry?: Registry;
|
||||||
environment: Environment;
|
environment: Environment;
|
||||||
}
|
}
|
||||||
|
@ -90,14 +97,14 @@ async function create({
|
||||||
}: CreateOptions) {
|
}: CreateOptions) {
|
||||||
await pullImageIfNeeded(
|
await pullImageIfNeeded(
|
||||||
environment.Id,
|
environment.Id,
|
||||||
|
values.alwaysPull || false,
|
||||||
|
values.imageName,
|
||||||
values.nodeName,
|
values.nodeName,
|
||||||
values.alwaysPull,
|
|
||||||
values.image.image,
|
|
||||||
registry
|
registry
|
||||||
);
|
);
|
||||||
|
|
||||||
const containerResponse = await createAndStart(
|
const containerResponse = await createAndStart(
|
||||||
environment,
|
environment.Id,
|
||||||
config,
|
config,
|
||||||
values.name,
|
values.name,
|
||||||
values.nodeName
|
values.nodeName
|
||||||
|
@ -106,8 +113,8 @@ async function create({
|
||||||
await applyContainerSettings(
|
await applyContainerSettings(
|
||||||
containerResponse.Id,
|
containerResponse.Id,
|
||||||
environment,
|
environment,
|
||||||
values.enableWebhook,
|
|
||||||
values.accessControl,
|
values.accessControl,
|
||||||
|
values.enableWebhook,
|
||||||
containerResponse.Portainer?.ResourceControl,
|
containerResponse.Portainer?.ResourceControl,
|
||||||
registry
|
registry
|
||||||
);
|
);
|
||||||
|
@ -123,33 +130,34 @@ async function replace({
|
||||||
}: ReplaceOptions) {
|
}: ReplaceOptions) {
|
||||||
await pullImageIfNeeded(
|
await pullImageIfNeeded(
|
||||||
environment.Id,
|
environment.Id,
|
||||||
|
values.alwaysPull || false,
|
||||||
|
values.imageName,
|
||||||
values.nodeName,
|
values.nodeName,
|
||||||
values.alwaysPull,
|
|
||||||
values.image.image,
|
|
||||||
registry
|
registry
|
||||||
);
|
);
|
||||||
|
|
||||||
const containerResponse = await renameAndCreate(
|
const containerResponse = await renameAndCreate(
|
||||||
environment,
|
environment.Id,
|
||||||
values,
|
values.name,
|
||||||
oldContainer,
|
oldContainer,
|
||||||
config
|
config,
|
||||||
|
values.nodeName
|
||||||
);
|
);
|
||||||
|
|
||||||
await applyContainerSettings(
|
await applyContainerSettings(
|
||||||
containerResponse.Id,
|
containerResponse.Id,
|
||||||
environment,
|
environment,
|
||||||
values.enableWebhook,
|
|
||||||
values.accessControl,
|
values.accessControl,
|
||||||
|
values.enableWebhook,
|
||||||
containerResponse.Portainer?.ResourceControl,
|
containerResponse.Portainer?.ResourceControl,
|
||||||
registry
|
registry
|
||||||
);
|
);
|
||||||
|
|
||||||
await connectToExtraNetworks(
|
await connectToExtraNetworks(
|
||||||
environment.Id,
|
environment.Id,
|
||||||
values.nodeName,
|
|
||||||
containerResponse.Id,
|
containerResponse.Id,
|
||||||
extraNetworks
|
extraNetworks,
|
||||||
|
values.nodeName
|
||||||
);
|
);
|
||||||
|
|
||||||
await removeContainer(environment.Id, oldContainer.Id, {
|
await removeContainer(environment.Id, oldContainer.Id, {
|
||||||
|
@ -162,33 +170,29 @@ async function replace({
|
||||||
* on any failure, it will rename the old container to its original name
|
* on any failure, it will rename the old container to its original name
|
||||||
*/
|
*/
|
||||||
async function renameAndCreate(
|
async function renameAndCreate(
|
||||||
environment: Environment,
|
environmentId: EnvironmentId,
|
||||||
values: Values,
|
name: string,
|
||||||
oldContainer: DockerContainer,
|
oldContainer: DockerContainer,
|
||||||
config: CreateContainerRequest
|
config: CreateContainerRequest,
|
||||||
|
nodeName?: string
|
||||||
) {
|
) {
|
||||||
let renamed = false;
|
let renamed = false;
|
||||||
try {
|
try {
|
||||||
await stopContainerIfNeeded(environment.Id, values.nodeName, oldContainer);
|
await stopContainerIfNeeded(environmentId, oldContainer, nodeName);
|
||||||
|
|
||||||
await renameContainer(
|
await renameContainer(
|
||||||
environment.Id,
|
environmentId,
|
||||||
oldContainer.Id,
|
oldContainer.Id,
|
||||||
`${oldContainer.Names[0]}-old`,
|
`${oldContainer.Names[0]}-old`,
|
||||||
{ nodeName: values.nodeName }
|
{ nodeName }
|
||||||
);
|
);
|
||||||
renamed = true;
|
renamed = true;
|
||||||
|
|
||||||
return await createAndStart(
|
return await createAndStart(environmentId, config, name, nodeName);
|
||||||
environment,
|
|
||||||
config,
|
|
||||||
values.name,
|
|
||||||
values.nodeName
|
|
||||||
);
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (renamed) {
|
if (renamed) {
|
||||||
await renameContainer(environment.Id, oldContainer.Id, values.name, {
|
await renameContainer(environmentId, oldContainer.Id, name, {
|
||||||
nodeName: values.nodeName,
|
nodeName,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
throw e;
|
throw e;
|
||||||
|
@ -201,8 +205,8 @@ async function renameAndCreate(
|
||||||
async function applyContainerSettings(
|
async function applyContainerSettings(
|
||||||
containerId: string,
|
containerId: string,
|
||||||
environment: Environment,
|
environment: Environment,
|
||||||
enableWebhook: boolean,
|
|
||||||
accessControl: AccessControlFormData,
|
accessControl: AccessControlFormData,
|
||||||
|
enableWebhook?: boolean,
|
||||||
resourceControl?: ResourceControlResponse,
|
resourceControl?: ResourceControlResponse,
|
||||||
registry?: Registry
|
registry?: Registry
|
||||||
) {
|
) {
|
||||||
|
@ -224,15 +228,15 @@ async function applyContainerSettings(
|
||||||
* on failure, it will remove the new container
|
* on failure, it will remove the new container
|
||||||
*/
|
*/
|
||||||
async function createAndStart(
|
async function createAndStart(
|
||||||
environment: Environment,
|
environmentId: EnvironmentId,
|
||||||
config: CreateContainerRequest,
|
config: CreateContainerRequest,
|
||||||
name: string,
|
name: string,
|
||||||
nodeName: string
|
nodeName?: string
|
||||||
) {
|
) {
|
||||||
let containerId = '';
|
let containerId = '';
|
||||||
try {
|
try {
|
||||||
const containerResponse = await createContainer(
|
const containerResponse = await createContainer(
|
||||||
environment.Id,
|
environmentId,
|
||||||
config,
|
config,
|
||||||
name,
|
name,
|
||||||
{
|
{
|
||||||
|
@ -242,11 +246,11 @@ async function createAndStart(
|
||||||
|
|
||||||
containerId = containerResponse.Id;
|
containerId = containerResponse.Id;
|
||||||
|
|
||||||
await startContainer(environment.Id, containerResponse.Id, { nodeName });
|
await startContainer(environmentId, containerResponse.Id, { nodeName });
|
||||||
return containerResponse;
|
return containerResponse;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (containerId) {
|
if (containerId) {
|
||||||
await removeContainer(environment.Id, containerId, {
|
await removeContainer(environmentId, containerId, {
|
||||||
nodeName,
|
nodeName,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
@ -257,9 +261,9 @@ async function createAndStart(
|
||||||
|
|
||||||
async function pullImageIfNeeded(
|
async function pullImageIfNeeded(
|
||||||
environmentId: EnvironmentId,
|
environmentId: EnvironmentId,
|
||||||
nodeName: string,
|
|
||||||
pull: boolean,
|
pull: boolean,
|
||||||
image: string,
|
image: string,
|
||||||
|
nodeName?: string,
|
||||||
registry?: Registry
|
registry?: Registry
|
||||||
) {
|
) {
|
||||||
if (!pull) {
|
if (!pull) {
|
||||||
|
@ -322,9 +326,9 @@ async function createContainerWebhook(
|
||||||
|
|
||||||
function connectToExtraNetworks(
|
function connectToExtraNetworks(
|
||||||
environmentId: EnvironmentId,
|
environmentId: EnvironmentId,
|
||||||
nodeName: string,
|
|
||||||
containerId: string,
|
containerId: string,
|
||||||
extraNetworks: Array<ExtraNetwork>
|
extraNetworks: Array<ExtraNetwork>,
|
||||||
|
nodeName?: string
|
||||||
) {
|
) {
|
||||||
if (!extraNetworks) {
|
if (!extraNetworks) {
|
||||||
return null;
|
return null;
|
||||||
|
@ -345,8 +349,8 @@ function connectToExtraNetworks(
|
||||||
|
|
||||||
function stopContainerIfNeeded(
|
function stopContainerIfNeeded(
|
||||||
environmentId: EnvironmentId,
|
environmentId: EnvironmentId,
|
||||||
nodeName: string,
|
container: DockerContainer,
|
||||||
container: DockerContainer
|
nodeName?: string
|
||||||
) {
|
) {
|
||||||
if (container.State !== 'running' || !container.Id) {
|
if (container.State !== 'running' || !container.Id) {
|
||||||
return null;
|
return null;
|
||||||
|
|
|
@ -13,7 +13,9 @@ interface Props {
|
||||||
export function ListView({ endpoint: environment }: Props) {
|
export function ListView({ endpoint: environment }: Props) {
|
||||||
const isAgent = isAgentEnvironment(environment.Type);
|
const isAgent = isAgentEnvironment(environment.Type);
|
||||||
|
|
||||||
const envInfoQuery = useInfo(environment.Id, (info) => !!info.Swarm?.NodeID);
|
const envInfoQuery = useInfo(environment.Id, {
|
||||||
|
select: (info) => !!info.Swarm?.NodeID,
|
||||||
|
});
|
||||||
|
|
||||||
const isSwarmManager = !!envInfoQuery.data;
|
const isSwarmManager = !!envInfoQuery.data;
|
||||||
const isHostColumnVisible = isAgent && isSwarmManager;
|
const isHostColumnVisible = isAgent && isSwarmManager;
|
||||||
|
|
|
@ -2,7 +2,7 @@ import _ from 'lodash';
|
||||||
|
|
||||||
import { ResourceControlViewModel } from '@/react/portainer/access-control/models/ResourceControlViewModel';
|
import { ResourceControlViewModel } from '@/react/portainer/access-control/models/ResourceControlViewModel';
|
||||||
import { EnvironmentId } from '@/react/portainer/environments/types';
|
import { EnvironmentId } from '@/react/portainer/environments/types';
|
||||||
import { useInfo } from '@/react/docker/proxy/queries/useInfo';
|
import { useIsStandAlone } from '@/react/docker/proxy/queries/useInfo';
|
||||||
import { useEnvironment } from '@/react/portainer/environments/queries';
|
import { useEnvironment } from '@/react/portainer/environments/queries';
|
||||||
|
|
||||||
import { DockerContainer, ContainerStatus } from './types';
|
import { DockerContainer, ContainerStatus } from './types';
|
||||||
|
@ -95,14 +95,11 @@ function createStatus(statusText = ''): ContainerStatus {
|
||||||
return ContainerStatus.Running;
|
return ContainerStatus.Running;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useShowGPUsColumn(environmentID: EnvironmentId) {
|
export function useShowGPUsColumn(environmentId: EnvironmentId) {
|
||||||
const isDockerStandaloneQuery = useInfo(
|
const isDockerStandalone = useIsStandAlone(environmentId);
|
||||||
environmentID,
|
|
||||||
(info) => !(!!info.Swarm?.NodeID && !!info.Swarm?.ControlAvailable) // is not a swarm environment, therefore docker standalone
|
|
||||||
);
|
|
||||||
const enableGPUManagementQuery = useEnvironment(
|
const enableGPUManagementQuery = useEnvironment(
|
||||||
environmentID,
|
environmentId,
|
||||||
(env) => env?.EnableGPUManagement
|
(env) => env?.EnableGPUManagement
|
||||||
);
|
);
|
||||||
return isDockerStandaloneQuery.data && enableGPUManagementQuery.data;
|
return isDockerStandalone && enableGPUManagementQuery.data;
|
||||||
}
|
}
|
||||||
|
|
|
@ -18,26 +18,38 @@ export async function getInfo(environmentId: EnvironmentId) {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useInfo<TSelect = SystemInfo>(
|
export function useInfo<TSelect = SystemInfo>(
|
||||||
environmentId: EnvironmentId,
|
environmentId?: EnvironmentId,
|
||||||
select?: (info: SystemInfo) => TSelect
|
{
|
||||||
|
enabled,
|
||||||
|
select,
|
||||||
|
}: { select?: (info: SystemInfo) => TSelect; enabled?: boolean } = {}
|
||||||
) {
|
) {
|
||||||
return useQuery(
|
return useQuery(
|
||||||
['environment', environmentId, 'docker', 'info'],
|
['environment', environmentId, 'docker', 'info'],
|
||||||
() => getInfo(environmentId),
|
() => getInfo(environmentId!),
|
||||||
{
|
{
|
||||||
select,
|
select,
|
||||||
|
enabled: !!environmentId && enabled,
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useIsStandAlone(environmentId: EnvironmentId) {
|
export function useIsStandAlone(environmentId: EnvironmentId) {
|
||||||
const query = useInfo(environmentId, (info) => !info.Swarm?.NodeID);
|
const query = useInfo(environmentId, {
|
||||||
|
select: (info) => !info.Swarm?.NodeID,
|
||||||
|
});
|
||||||
|
|
||||||
return !!query.data;
|
return !!query.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useIsSwarm(environmentId: EnvironmentId) {
|
export function useIsSwarm(
|
||||||
const query = useInfo(environmentId, (info) => !!info.Swarm?.NodeID);
|
environmentId?: EnvironmentId,
|
||||||
|
{ enabled }: { enabled?: boolean } = {}
|
||||||
|
) {
|
||||||
|
const query = useInfo(environmentId, {
|
||||||
|
select: (info) => !!info.Swarm?.NodeID,
|
||||||
|
enabled,
|
||||||
|
});
|
||||||
|
|
||||||
return !!query.data;
|
return !!query.data;
|
||||||
}
|
}
|
||||||
|
|
|
@ -41,7 +41,9 @@ export function useServicePlugins(
|
||||||
pluginType: keyof PluginsInfo,
|
pluginType: keyof PluginsInfo,
|
||||||
pluginVersion: string
|
pluginVersion: string
|
||||||
) {
|
) {
|
||||||
const systemPluginsQuery = useInfo(environmentId, (info) => info.Plugins);
|
const systemPluginsQuery = useInfo(environmentId, {
|
||||||
|
select: (info) => info.Plugins,
|
||||||
|
});
|
||||||
const pluginsQuery = usePlugins(environmentId, { enabled: !systemOnly });
|
const pluginsQuery = usePlugins(environmentId, { enabled: !systemOnly });
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|
|
@ -0,0 +1,38 @@
|
||||||
|
import { useQuery } from 'react-query';
|
||||||
|
import { Swarm } from 'docker-types/generated/1.41';
|
||||||
|
|
||||||
|
import axios, { parseAxiosError } from '@/portainer/services/axios';
|
||||||
|
import { EnvironmentId } from '@/react/portainer/environments/types';
|
||||||
|
|
||||||
|
import { queryKeys } from './query-keys';
|
||||||
|
import { buildUrl } from './build-url';
|
||||||
|
import { useIsSwarm } from './useInfo';
|
||||||
|
|
||||||
|
export function useSwarm<T = Swarm>(
|
||||||
|
environmentId: EnvironmentId,
|
||||||
|
{ select }: { select?(value: Swarm): T } = {}
|
||||||
|
) {
|
||||||
|
const isSwarm = useIsSwarm(environmentId);
|
||||||
|
|
||||||
|
return useQuery({
|
||||||
|
queryKey: [...queryKeys.base(environmentId), 'swarm'] as const,
|
||||||
|
queryFn: () => getSwarm(environmentId),
|
||||||
|
select,
|
||||||
|
enabled: isSwarm,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getSwarm(environmentId: EnvironmentId) {
|
||||||
|
try {
|
||||||
|
const { data } = await axios.get<Swarm>(buildUrl(environmentId, 'swarm'));
|
||||||
|
return data;
|
||||||
|
} catch (err) {
|
||||||
|
throw parseAxiosError(err, 'Unable to retrieve swarm information');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useSwarmId(environmentId: EnvironmentId) {
|
||||||
|
return useSwarm(environmentId, {
|
||||||
|
select: (swarm) => swarm.ID,
|
||||||
|
});
|
||||||
|
}
|
|
@ -18,19 +18,20 @@ export async function getVersion(environmentId: EnvironmentId) {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useVersion<TSelect = SystemVersion>(
|
export function useVersion<TSelect = SystemVersion>(
|
||||||
environmentId: EnvironmentId,
|
environmentId?: EnvironmentId,
|
||||||
select?: (info: SystemVersion) => TSelect
|
select?: (info: SystemVersion) => TSelect
|
||||||
) {
|
) {
|
||||||
return useQuery(
|
return useQuery(
|
||||||
['environment', environmentId, 'docker', 'version'],
|
['environment', environmentId!, 'docker', 'version'],
|
||||||
() => getVersion(environmentId),
|
() => getVersion(environmentId!),
|
||||||
{
|
{
|
||||||
select,
|
select,
|
||||||
|
enabled: !!environmentId,
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useApiVersion(environmentId: EnvironmentId) {
|
export function useApiVersion(environmentId?: EnvironmentId) {
|
||||||
const query = useVersion(environmentId, (info) => info.ApiVersion);
|
const query = useVersion(environmentId, (info) => info.ApiVersion);
|
||||||
return query.data ? parseFloat(query.data) : 0;
|
return query.data ? parseFloat(query.data) : 0;
|
||||||
}
|
}
|
||||||
|
|
|
@ -67,8 +67,8 @@ export class StackViewModel implements IResource {
|
||||||
this.Id = stack.Id;
|
this.Id = stack.Id;
|
||||||
this.Type = stack.Type;
|
this.Type = stack.Type;
|
||||||
this.Name = stack.Name;
|
this.Name = stack.Name;
|
||||||
this.EndpointId = stack.EndpointID;
|
this.EndpointId = stack.EndpointId;
|
||||||
this.SwarmId = stack.SwarmID;
|
this.SwarmId = stack.SwarmId;
|
||||||
this.Env = stack.Env ? stack.Env : [];
|
this.Env = stack.Env ? stack.Env : [];
|
||||||
this.Option = stack.Option;
|
this.Option = stack.Option;
|
||||||
this.IsComposeFormat = stack.IsComposeFormat;
|
this.IsComposeFormat = stack.IsComposeFormat;
|
||||||
|
|
|
@ -0,0 +1,243 @@
|
||||||
|
import { useRouter } from '@uirouter/react';
|
||||||
|
import { Formik, Form } from 'formik';
|
||||||
|
|
||||||
|
import { notifySuccess } from '@/portainer/services/notifications';
|
||||||
|
import {
|
||||||
|
CreateStackPayload,
|
||||||
|
useCreateStack,
|
||||||
|
} from '@/react/common/stacks/queries/useCreateStack/useCreateStack';
|
||||||
|
import { useEnvironmentId } from '@/react/hooks/useEnvironmentId';
|
||||||
|
import { useCurrentUser, useIsEdgeAdmin } from '@/react/hooks/useUser';
|
||||||
|
import { AccessControlForm } from '@/react/portainer/access-control';
|
||||||
|
import { parseAccessControlFormData } from '@/react/portainer/access-control/utils';
|
||||||
|
import { NameField } from '@/react/common/stacks/CreateView/NameField';
|
||||||
|
import { CustomTemplate } from '@/react/portainer/templates/custom-templates/types';
|
||||||
|
import {
|
||||||
|
isTemplateVariablesEnabled,
|
||||||
|
renderTemplate,
|
||||||
|
} from '@/react/portainer/custom-templates/components/utils';
|
||||||
|
import {
|
||||||
|
CustomTemplatesVariablesField,
|
||||||
|
getVariablesFieldDefaultValues,
|
||||||
|
} from '@/react/portainer/custom-templates/components/CustomTemplatesVariablesField';
|
||||||
|
import { StackType } from '@/react/common/stacks/types';
|
||||||
|
import { toGitFormModel } from '@/react/portainer/gitops/types';
|
||||||
|
import { AdvancedSettings } from '@/react/portainer/templates/app-templates/DeployFormWidget/AdvancedSettings';
|
||||||
|
|
||||||
|
import { Button } from '@@/buttons';
|
||||||
|
import { FormActions } from '@@/form-components/FormActions';
|
||||||
|
import { FormSection } from '@@/form-components/FormSection';
|
||||||
|
import { WebEditorForm } from '@@/WebEditorForm';
|
||||||
|
|
||||||
|
import { useSwarmId } from '../../proxy/queries/useSwarm';
|
||||||
|
|
||||||
|
import { FormValues } from './types';
|
||||||
|
import { useValidation } from './useValidation';
|
||||||
|
|
||||||
|
export function DeployForm({
|
||||||
|
template,
|
||||||
|
unselect,
|
||||||
|
templateFile,
|
||||||
|
isDeployable,
|
||||||
|
}: {
|
||||||
|
template: CustomTemplate;
|
||||||
|
templateFile: string;
|
||||||
|
unselect: () => void;
|
||||||
|
isDeployable: boolean;
|
||||||
|
}) {
|
||||||
|
const router = useRouter();
|
||||||
|
const { user } = useCurrentUser();
|
||||||
|
const isEdgeAdminQuery = useIsEdgeAdmin();
|
||||||
|
const environmentId = useEnvironmentId();
|
||||||
|
const swarmIdQuery = useSwarmId(environmentId);
|
||||||
|
const mutation = useCreateStack();
|
||||||
|
const validation = useValidation({
|
||||||
|
isDeployable,
|
||||||
|
variableDefs: template.Variables,
|
||||||
|
isAdmin: isEdgeAdminQuery.isAdmin,
|
||||||
|
environmentId,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (isEdgeAdminQuery.isLoading) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const isGit = !!template.GitConfig;
|
||||||
|
|
||||||
|
const initialValues: FormValues = {
|
||||||
|
name: template.Title || '',
|
||||||
|
variables: getVariablesFieldDefaultValues(template.Variables),
|
||||||
|
accessControl: parseAccessControlFormData(
|
||||||
|
isEdgeAdminQuery.isAdmin,
|
||||||
|
user.Id
|
||||||
|
),
|
||||||
|
fileContent: templateFile,
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Formik
|
||||||
|
initialValues={initialValues}
|
||||||
|
onSubmit={handleSubmit}
|
||||||
|
validationSchema={validation}
|
||||||
|
validateOnMount
|
||||||
|
>
|
||||||
|
{({ values, errors, setFieldValue, isValid }) => (
|
||||||
|
<Form className="form-horizontal">
|
||||||
|
<FormSection title="Configuration">
|
||||||
|
<NameField
|
||||||
|
value={values.name}
|
||||||
|
onChange={(v) => setFieldValue('name', v)}
|
||||||
|
errors={errors.name}
|
||||||
|
/>
|
||||||
|
</FormSection>
|
||||||
|
|
||||||
|
{isTemplateVariablesEnabled && (
|
||||||
|
<CustomTemplatesVariablesField
|
||||||
|
definitions={template.Variables}
|
||||||
|
onChange={(v) => {
|
||||||
|
setFieldValue('variables', v);
|
||||||
|
const newFile = renderTemplate(
|
||||||
|
templateFile,
|
||||||
|
v,
|
||||||
|
template.Variables
|
||||||
|
);
|
||||||
|
setFieldValue('fileContent', newFile);
|
||||||
|
}}
|
||||||
|
value={values.variables}
|
||||||
|
errors={errors.variables}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<AdvancedSettings
|
||||||
|
label={(isOpen) => advancedSettingsLabel(isOpen, isGit)}
|
||||||
|
>
|
||||||
|
<WebEditorForm
|
||||||
|
id="custom-template-creation-editor"
|
||||||
|
value={values.fileContent}
|
||||||
|
onChange={(value) => {
|
||||||
|
if (isGit) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setFieldValue('fileContent', value);
|
||||||
|
}}
|
||||||
|
yaml
|
||||||
|
error={errors.fileContent}
|
||||||
|
placeholder="Define or paste the content of your docker compose file here"
|
||||||
|
readonly={isGit}
|
||||||
|
data-cy="custom-template-creation-editor"
|
||||||
|
>
|
||||||
|
<p>
|
||||||
|
You can get more information about Compose file format in the{' '}
|
||||||
|
<a
|
||||||
|
href="https://docs.docker.com/compose/compose-file/"
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
>
|
||||||
|
official documentation
|
||||||
|
</a>
|
||||||
|
.
|
||||||
|
</p>
|
||||||
|
</WebEditorForm>
|
||||||
|
</AdvancedSettings>
|
||||||
|
|
||||||
|
<AccessControlForm
|
||||||
|
formNamespace="accessControl"
|
||||||
|
onChange={(values) => setFieldValue('accessControl', values)}
|
||||||
|
values={values.accessControl}
|
||||||
|
errors={errors.accessControl}
|
||||||
|
environmentId={environmentId}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormActions
|
||||||
|
isLoading={mutation.isLoading}
|
||||||
|
isValid={isValid}
|
||||||
|
loadingText="Deployment in progress..."
|
||||||
|
submitLabel="Deploy the stack"
|
||||||
|
data-cy="deploy-stack-button"
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
type="reset"
|
||||||
|
onClick={() => unselect()}
|
||||||
|
color="default"
|
||||||
|
data-cy="cancel-stack-creation"
|
||||||
|
>
|
||||||
|
Hide
|
||||||
|
</Button>
|
||||||
|
</FormActions>
|
||||||
|
</Form>
|
||||||
|
)}
|
||||||
|
</Formik>
|
||||||
|
);
|
||||||
|
|
||||||
|
function handleSubmit(values: FormValues) {
|
||||||
|
const payload = getPayload(values);
|
||||||
|
|
||||||
|
return mutation.mutate(payload, {
|
||||||
|
onSuccess() {
|
||||||
|
notifySuccess('Success', 'Stack created');
|
||||||
|
router.stateService.go('docker.stacks');
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function getPayload(values: FormValues): CreateStackPayload {
|
||||||
|
const type =
|
||||||
|
template.Type === StackType.DockerCompose ? 'standalone' : 'swarm';
|
||||||
|
const isGit = !!template.GitConfig;
|
||||||
|
if (isGit) {
|
||||||
|
return type === 'standalone'
|
||||||
|
? {
|
||||||
|
type,
|
||||||
|
method: 'git',
|
||||||
|
payload: {
|
||||||
|
name: values.name,
|
||||||
|
environmentId,
|
||||||
|
git: toGitFormModel(template.GitConfig),
|
||||||
|
accessControl: values.accessControl,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
type,
|
||||||
|
method: 'git',
|
||||||
|
payload: {
|
||||||
|
name: values.name,
|
||||||
|
environmentId,
|
||||||
|
swarmId: swarmIdQuery.data || '',
|
||||||
|
git: toGitFormModel(template.GitConfig),
|
||||||
|
accessControl: values.accessControl,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return type === 'standalone'
|
||||||
|
? {
|
||||||
|
type,
|
||||||
|
method: 'string',
|
||||||
|
payload: {
|
||||||
|
name: values.name,
|
||||||
|
environmentId,
|
||||||
|
fileContent: values.fileContent,
|
||||||
|
accessControl: values.accessControl,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
type,
|
||||||
|
method: 'string',
|
||||||
|
payload: {
|
||||||
|
name: values.name,
|
||||||
|
environmentId,
|
||||||
|
swarmId: swarmIdQuery.data || '',
|
||||||
|
fileContent: values.fileContent,
|
||||||
|
accessControl: values.accessControl,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function advancedSettingsLabel(isOpen: boolean, isGit: boolean) {
|
||||||
|
if (isGit) {
|
||||||
|
return isOpen ? 'Hide stack' : 'View stack';
|
||||||
|
}
|
||||||
|
|
||||||
|
return isOpen ? 'Hide custom stack' : 'Customize stack';
|
||||||
|
}
|
|
@ -0,0 +1,57 @@
|
||||||
|
import { DeployWidget } from '@/react/portainer/templates/components/DeployWidget';
|
||||||
|
import { CustomTemplate } from '@/react/portainer/templates/custom-templates/types';
|
||||||
|
import { useCustomTemplateFile } from '@/react/portainer/templates/custom-templates/queries/useCustomTemplateFile';
|
||||||
|
|
||||||
|
import { TextTip } from '@@/Tip/TextTip';
|
||||||
|
|
||||||
|
import { useIsDeployable } from './useIsDeployable';
|
||||||
|
import { DeployForm } from './DeployForm';
|
||||||
|
import { TemplateLoadError } from './TemplateLoadError';
|
||||||
|
|
||||||
|
export function StackFromCustomTemplateFormWidget({
|
||||||
|
template,
|
||||||
|
unselect,
|
||||||
|
}: {
|
||||||
|
template: CustomTemplate;
|
||||||
|
unselect: () => void;
|
||||||
|
}) {
|
||||||
|
const isDeployable = useIsDeployable(template.Type);
|
||||||
|
const fileQuery = useCustomTemplateFile(template.Id);
|
||||||
|
|
||||||
|
if (fileQuery.isLoading) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DeployWidget
|
||||||
|
logo={template.Logo}
|
||||||
|
note={template.Note}
|
||||||
|
title={template.Title}
|
||||||
|
>
|
||||||
|
{fileQuery.isError && (
|
||||||
|
<TemplateLoadError
|
||||||
|
creatorId={template.CreatedByUserId}
|
||||||
|
templateId={template.Id}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!isDeployable && (
|
||||||
|
<div className="form-group">
|
||||||
|
<div className="col-sm-12">
|
||||||
|
<TextTip>
|
||||||
|
This template type cannot be deployed on this environment.
|
||||||
|
</TextTip>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{fileQuery.isSuccess && isDeployable && (
|
||||||
|
<DeployForm
|
||||||
|
template={template}
|
||||||
|
unselect={unselect}
|
||||||
|
templateFile={fileQuery.data}
|
||||||
|
isDeployable={isDeployable}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</DeployWidget>
|
||||||
|
);
|
||||||
|
}
|
|
@ -0,0 +1,46 @@
|
||||||
|
import { UserId } from '@/portainer/users/types';
|
||||||
|
import { useCurrentUser, useIsEdgeAdmin } from '@/react/hooks/useUser';
|
||||||
|
import { CustomTemplate } from '@/react/portainer/templates/custom-templates/types';
|
||||||
|
|
||||||
|
import { Link } from '@@/Link';
|
||||||
|
import { FormError } from '@@/form-components/FormError';
|
||||||
|
|
||||||
|
export function TemplateLoadError({
|
||||||
|
templateId,
|
||||||
|
creatorId,
|
||||||
|
}: {
|
||||||
|
templateId: CustomTemplate['Id'];
|
||||||
|
creatorId: UserId;
|
||||||
|
}) {
|
||||||
|
const { user } = useCurrentUser();
|
||||||
|
const isEdgeAdminQuery = useIsEdgeAdmin();
|
||||||
|
|
||||||
|
if (isEdgeAdminQuery.isLoading) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const isAdminOrWriter = isEdgeAdminQuery.isAdmin || user.Id === creatorId;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<FormError>
|
||||||
|
{isAdminOrWriter ? (
|
||||||
|
<>
|
||||||
|
Custom template could not be loaded, please{' '}
|
||||||
|
<Link
|
||||||
|
to=".edit"
|
||||||
|
params={{ id: templateId }}
|
||||||
|
data-cy="edit-custom-template-link"
|
||||||
|
>
|
||||||
|
click here
|
||||||
|
</Link>{' '}
|
||||||
|
for configuration
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
Custom template could not be loaded, please contact your
|
||||||
|
administrator.
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</FormError>
|
||||||
|
);
|
||||||
|
}
|
|
@ -0,0 +1 @@
|
||||||
|
export { StackFromCustomTemplateFormWidget } from './StackFromCustomTemplateFormWidget';
|
|
@ -0,0 +1,9 @@
|
||||||
|
import { AccessControlFormData } from '@/react/portainer/access-control/types';
|
||||||
|
import { VariablesFieldValue } from '@/react/portainer/custom-templates/components/CustomTemplatesVariablesField';
|
||||||
|
|
||||||
|
export interface FormValues {
|
||||||
|
name: string;
|
||||||
|
variables: VariablesFieldValue;
|
||||||
|
accessControl: AccessControlFormData;
|
||||||
|
fileContent: string;
|
||||||
|
}
|
|
@ -0,0 +1,20 @@
|
||||||
|
import { useEnvironmentId } from '@/react/hooks/useEnvironmentId';
|
||||||
|
import { StackType } from '@/react/common/stacks/types';
|
||||||
|
|
||||||
|
import { useIsSwarm } from '../../proxy/queries/useInfo';
|
||||||
|
|
||||||
|
export function useIsDeployable(type: StackType) {
|
||||||
|
const environmentId = useEnvironmentId();
|
||||||
|
|
||||||
|
const isSwarm = useIsSwarm(environmentId);
|
||||||
|
|
||||||
|
switch (type) {
|
||||||
|
case StackType.DockerCompose:
|
||||||
|
return !isSwarm;
|
||||||
|
case StackType.DockerSwarm:
|
||||||
|
return isSwarm;
|
||||||
|
case StackType.Kubernetes:
|
||||||
|
default:
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,37 @@
|
||||||
|
import { useMemo } from 'react';
|
||||||
|
import { object, string } from 'yup';
|
||||||
|
|
||||||
|
import { accessControlFormValidation } from '@/react/portainer/access-control/AccessControlForm';
|
||||||
|
import { useNameValidation } from '@/react/common/stacks/CreateView/NameField';
|
||||||
|
import { variablesFieldValidation } from '@/react/portainer/custom-templates/components/CustomTemplatesVariablesField';
|
||||||
|
import { VariableDefinition } from '@/react/portainer/custom-templates/components/CustomTemplatesVariablesDefinitionField';
|
||||||
|
import { EnvironmentId } from '@/react/portainer/environments/types';
|
||||||
|
|
||||||
|
export function useValidation({
|
||||||
|
environmentId,
|
||||||
|
isAdmin,
|
||||||
|
variableDefs,
|
||||||
|
isDeployable,
|
||||||
|
}: {
|
||||||
|
variableDefs: Array<VariableDefinition>;
|
||||||
|
isAdmin: boolean;
|
||||||
|
environmentId: EnvironmentId;
|
||||||
|
isDeployable: boolean;
|
||||||
|
}) {
|
||||||
|
const name = useNameValidation(environmentId);
|
||||||
|
|
||||||
|
return useMemo(
|
||||||
|
() =>
|
||||||
|
object({
|
||||||
|
name: name.test({
|
||||||
|
name: 'is-deployable',
|
||||||
|
message: 'This template cannot be deployed on this environment',
|
||||||
|
test: () => isDeployable,
|
||||||
|
}),
|
||||||
|
accessControl: accessControlFormValidation(isAdmin),
|
||||||
|
fileContent: string().required('Required'),
|
||||||
|
variables: variablesFieldValidation(variableDefs),
|
||||||
|
}),
|
||||||
|
[isAdmin, isDeployable, name, variableDefs]
|
||||||
|
);
|
||||||
|
}
|
|
@ -0,0 +1,19 @@
|
||||||
|
import { buildUrl as buildProxyUrl } from '@/react/docker/proxy/queries/build-url';
|
||||||
|
import { EnvironmentId } from '@/react/portainer/environments/types';
|
||||||
|
|
||||||
|
export function buildUrl(
|
||||||
|
environmentId: EnvironmentId,
|
||||||
|
{ action, id }: { id?: string; action?: string } = {}
|
||||||
|
) {
|
||||||
|
let url = buildProxyUrl(environmentId, 'volumes');
|
||||||
|
|
||||||
|
if (id) {
|
||||||
|
url += `/${id}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (action) {
|
||||||
|
url += `/${action}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return url;
|
||||||
|
}
|
|
@ -0,0 +1,26 @@
|
||||||
|
import { Volume, VolumeCreateOptions } from 'docker-types/generated/1.41';
|
||||||
|
|
||||||
|
import axios, { parseAxiosError } from '@/portainer/services/axios';
|
||||||
|
import { EnvironmentId } from '@/react/portainer/environments/types';
|
||||||
|
|
||||||
|
import { buildUrl } from './build-url';
|
||||||
|
|
||||||
|
export async function createVolume(
|
||||||
|
environmentId: EnvironmentId,
|
||||||
|
volume: VolumeCreateOptions
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const { data } = await axios.post<Volume>(
|
||||||
|
buildUrl(environmentId, { action: 'create' }),
|
||||||
|
volume,
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
'X-Portainer-VolumeName': volume.Name || '',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
return data;
|
||||||
|
} catch (error) {
|
||||||
|
throw parseAxiosError(error);
|
||||||
|
}
|
||||||
|
}
|
|
@ -2,10 +2,10 @@ import { useQuery } from 'react-query';
|
||||||
import { Volume } from 'docker-types/generated/1.41';
|
import { Volume } from 'docker-types/generated/1.41';
|
||||||
|
|
||||||
import axios, { parseAxiosError } from '@/portainer/services/axios';
|
import axios, { parseAxiosError } from '@/portainer/services/axios';
|
||||||
import { buildUrl as buildDockerUrl } from '@/react/docker/proxy/queries/build-url';
|
|
||||||
import { EnvironmentId } from '@/react/portainer/environments/types';
|
import { EnvironmentId } from '@/react/portainer/environments/types';
|
||||||
|
|
||||||
import { queryKeys } from './query-keys';
|
import { queryKeys } from './query-keys';
|
||||||
|
import { buildUrl } from './build-url';
|
||||||
|
|
||||||
export function useVolumes<T = Volume[]>(
|
export function useVolumes<T = Volume[]>(
|
||||||
environmentId: EnvironmentId,
|
environmentId: EnvironmentId,
|
||||||
|
@ -24,22 +24,10 @@ interface VolumesResponse {
|
||||||
|
|
||||||
export async function getVolumes(environmentId: EnvironmentId) {
|
export async function getVolumes(environmentId: EnvironmentId) {
|
||||||
try {
|
try {
|
||||||
const { data } = await axios.get<VolumesResponse>(
|
const { data } = await axios.get<VolumesResponse>(buildUrl(environmentId));
|
||||||
buildUrl(environmentId, 'volumes')
|
|
||||||
);
|
|
||||||
|
|
||||||
return data.Volumes;
|
return data.Volumes;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
throw parseAxiosError(error as Error, 'Unable to retrieve volumes');
|
throw parseAxiosError(error as Error, 'Unable to retrieve volumes');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildUrl(environmentId: EnvironmentId, action: string, id?: string) {
|
|
||||||
let url = buildDockerUrl(environmentId, action);
|
|
||||||
|
|
||||||
if (id) {
|
|
||||||
url += `/${id}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
return url;
|
|
||||||
}
|
|
||||||
|
|
|
@ -1,9 +1,11 @@
|
||||||
import { FormikErrors } from 'formik';
|
import { FormikErrors } from 'formik';
|
||||||
|
|
||||||
import { TemplateViewModel } from '@/react/portainer/templates/app-templates/view-model';
|
import { TemplateViewModel } from '@/react/portainer/templates/app-templates/view-model';
|
||||||
|
import {
|
||||||
import { EnvVarsFieldset } from './EnvVarsFieldset';
|
EnvVarsFieldset,
|
||||||
import { TemplateNote } from './TemplateNote';
|
EnvVarsValue,
|
||||||
|
} from '@/react/portainer/templates/app-templates/DeployFormWidget/EnvVarsFieldset';
|
||||||
|
import { TemplateNote } from '@/react/portainer/templates/components/TemplateNote';
|
||||||
|
|
||||||
export function AppTemplateFieldset({
|
export function AppTemplateFieldset({
|
||||||
template,
|
template,
|
||||||
|
@ -12,16 +14,16 @@ export function AppTemplateFieldset({
|
||||||
errors,
|
errors,
|
||||||
}: {
|
}: {
|
||||||
template: TemplateViewModel;
|
template: TemplateViewModel;
|
||||||
values: Record<string, string>;
|
values: EnvVarsValue;
|
||||||
onChange: (value: Record<string, string>) => void;
|
onChange: (value: EnvVarsValue) => void;
|
||||||
errors?: FormikErrors<Record<string, string>>;
|
errors?: FormikErrors<EnvVarsValue>;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<TemplateNote note={template.Note} />
|
<TemplateNote note={template.Note} />
|
||||||
<EnvVarsFieldset
|
<EnvVarsFieldset
|
||||||
options={template.Env || []}
|
options={template.Env || []}
|
||||||
value={values}
|
values={values}
|
||||||
onChange={onChange}
|
onChange={onChange}
|
||||||
errors={errors}
|
errors={errors}
|
||||||
/>
|
/>
|
||||||
|
|
|
@ -1,10 +1,10 @@
|
||||||
import { CustomTemplatesVariablesField } from '@/react/portainer/custom-templates/components/CustomTemplatesVariablesField';
|
import { CustomTemplatesVariablesField } from '@/react/portainer/custom-templates/components/CustomTemplatesVariablesField';
|
||||||
import { CustomTemplate } from '@/react/portainer/templates/custom-templates/types';
|
import { CustomTemplate } from '@/react/portainer/templates/custom-templates/types';
|
||||||
|
import { TemplateNote } from '@/react/portainer/templates/components/TemplateNote';
|
||||||
|
|
||||||
import { ArrayError } from '@@/form-components/InputList/InputList';
|
import { ArrayError } from '@@/form-components/InputList/InputList';
|
||||||
|
|
||||||
import { Values } from './types';
|
import { Values } from './types';
|
||||||
import { TemplateNote } from './TemplateNote';
|
|
||||||
|
|
||||||
export function CustomTemplateFieldset({
|
export function CustomTemplateFieldset({
|
||||||
errors,
|
errors,
|
||||||
|
|
|
@ -3,7 +3,8 @@ import { FormikErrors } from 'formik';
|
||||||
|
|
||||||
import { getVariablesFieldDefaultValues } from '@/react/portainer/custom-templates/components/CustomTemplatesVariablesField';
|
import { getVariablesFieldDefaultValues } from '@/react/portainer/custom-templates/components/CustomTemplatesVariablesField';
|
||||||
|
|
||||||
import { getDefaultValues as getAppVariablesDefaultValues } from './EnvVarsFieldset';
|
import { getDefaultValues as getAppVariablesDefaultValues } from '../../../../portainer/templates/app-templates/DeployFormWidget/EnvVarsFieldset';
|
||||||
|
|
||||||
import { TemplateSelector } from './TemplateSelector';
|
import { TemplateSelector } from './TemplateSelector';
|
||||||
import { SelectedTemplateValue, Values } from './types';
|
import { SelectedTemplateValue, Values } from './types';
|
||||||
import { CustomTemplateFieldset } from './CustomTemplateFieldset';
|
import { CustomTemplateFieldset } from './CustomTemplateFieldset';
|
||||||
|
|
|
@ -1,25 +0,0 @@
|
||||||
import { vi } from 'vitest';
|
|
||||||
import { render, screen } from '@testing-library/react';
|
|
||||||
|
|
||||||
import { TemplateNote } from './TemplateNote';
|
|
||||||
|
|
||||||
vi.mock('sanitize-html', () => ({
|
|
||||||
default: (note: string) => note, // Mock the sanitize-html library to return the input as is
|
|
||||||
}));
|
|
||||||
|
|
||||||
test('renders template note', async () => {
|
|
||||||
render(<TemplateNote note="Test note" />);
|
|
||||||
|
|
||||||
const templateNoteElement = screen.getByText(/Information/);
|
|
||||||
expect(templateNoteElement).toBeInTheDocument();
|
|
||||||
|
|
||||||
const noteElement = screen.getByText(/Test note/);
|
|
||||||
expect(noteElement).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('does not render template note when note is undefined', async () => {
|
|
||||||
render(<TemplateNote note={undefined} />);
|
|
||||||
|
|
||||||
const templateNoteElement = screen.queryByText(/Information/);
|
|
||||||
expect(templateNoteElement).not.toBeInTheDocument();
|
|
||||||
});
|
|
|
@ -2,17 +2,19 @@ import { mixed, object, SchemaOf, string } from 'yup';
|
||||||
|
|
||||||
import { variablesFieldValidation } from '@/react/portainer/custom-templates/components/CustomTemplatesVariablesField';
|
import { variablesFieldValidation } from '@/react/portainer/custom-templates/components/CustomTemplatesVariablesField';
|
||||||
import { VariableDefinition } from '@/react/portainer/custom-templates/components/CustomTemplatesVariablesDefinitionField';
|
import { VariableDefinition } from '@/react/portainer/custom-templates/components/CustomTemplatesVariablesDefinitionField';
|
||||||
|
import { envVarsFieldsetValidation } from '@/react/portainer/templates/app-templates/DeployFormWidget/EnvVarsFieldset';
|
||||||
|
import { TemplateEnv } from '@/react/portainer/templates/app-templates/types';
|
||||||
|
|
||||||
import { envVarsFieldsetValidation } from './EnvVarsFieldset';
|
function validation({
|
||||||
|
customVariablesDefinitions,
|
||||||
export function validation({
|
envVarDefinitions,
|
||||||
definitions,
|
|
||||||
}: {
|
}: {
|
||||||
definitions: VariableDefinition[];
|
customVariablesDefinitions: VariableDefinition[];
|
||||||
|
envVarDefinitions: Array<TemplateEnv>;
|
||||||
}) {
|
}) {
|
||||||
return object({
|
return object({
|
||||||
type: string().oneOf(['custom', 'app']).required(),
|
type: string().oneOf(['custom', 'app']).required(),
|
||||||
envVars: envVarsFieldsetValidation()
|
envVars: envVarsFieldsetValidation(envVarDefinitions)
|
||||||
.optional()
|
.optional()
|
||||||
.when('type', {
|
.when('type', {
|
||||||
is: 'app',
|
is: 'app',
|
||||||
|
@ -20,7 +22,7 @@ export function validation({
|
||||||
}),
|
}),
|
||||||
file: mixed().optional(),
|
file: mixed().optional(),
|
||||||
template: object().optional().default(null),
|
template: object().optional().default(null),
|
||||||
variables: variablesFieldValidation(definitions)
|
variables: variablesFieldValidation(customVariablesDefinitions)
|
||||||
.optional()
|
.optional()
|
||||||
.when('type', {
|
.when('type', {
|
||||||
is: 'custom',
|
is: 'custom',
|
||||||
|
|
|
@ -1,26 +0,0 @@
|
||||||
import { AppTemplatesList } from '@/react/portainer/templates/app-templates/AppTemplatesList';
|
|
||||||
import { useAppTemplates } from '@/react/portainer/templates/app-templates/queries/useAppTemplates';
|
|
||||||
import { TemplateType } from '@/react/portainer/templates/app-templates/types';
|
|
||||||
|
|
||||||
import { PageHeader } from '@@/PageHeader';
|
|
||||||
|
|
||||||
export function AppTemplatesView() {
|
|
||||||
const templatesQuery = useAppTemplates();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<PageHeader title="Application templates list" breadcrumbs="Templates" />
|
|
||||||
|
|
||||||
<AppTemplatesList
|
|
||||||
templates={templatesQuery.data}
|
|
||||||
templateLinkParams={(template) => ({
|
|
||||||
to: 'edge.stacks.new',
|
|
||||||
params: { templateId: template.Id, templateType: 'app' },
|
|
||||||
})}
|
|
||||||
disabledTypes={[TemplateType.Container]}
|
|
||||||
fixedCategories={['edge']}
|
|
||||||
storageKey="edge-app-templates"
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
|
@ -1 +0,0 @@
|
||||||
export { AppTemplatesView } from './AppTemplatesView';
|
|
|
@ -1 +1,2 @@
|
||||||
export { AccessControlForm } from './AccessControlForm';
|
export { AccessControlForm } from './AccessControlForm';
|
||||||
|
export { validationSchema as accessControlFormValidation } from './AccessControlForm.validation';
|
||||||
|
|
|
@ -0,0 +1,92 @@
|
||||||
|
import { useParamState } from '@/react/hooks/useParamState';
|
||||||
|
import { useEnvironmentId } from '@/react/hooks/useEnvironmentId';
|
||||||
|
import { useInfo } from '@/react/docker/proxy/queries/useInfo';
|
||||||
|
import { useApiVersion } from '@/react/docker/proxy/queries/useVersion';
|
||||||
|
import { useAuthorizations } from '@/react/hooks/useUser';
|
||||||
|
|
||||||
|
import { PageHeader } from '@@/PageHeader';
|
||||||
|
|
||||||
|
import { TemplateType } from './types';
|
||||||
|
import { useAppTemplates } from './queries/useAppTemplates';
|
||||||
|
import { AppTemplatesList } from './AppTemplatesList';
|
||||||
|
import { DeployForm } from './DeployFormWidget/DeployFormWidget';
|
||||||
|
|
||||||
|
export function AppTemplatesView() {
|
||||||
|
const envId = useEnvironmentId(false);
|
||||||
|
|
||||||
|
const hasCreateAuthQuery = useAuthorizations([
|
||||||
|
'DockerContainerCreate',
|
||||||
|
'PortainerStackCreate',
|
||||||
|
]);
|
||||||
|
const [selectedTemplateId, setSelectedTemplateId] = useParamState<number>(
|
||||||
|
'template',
|
||||||
|
(param) => (param ? parseInt(param, 10) : 0)
|
||||||
|
);
|
||||||
|
const templatesQuery = useAppTemplates();
|
||||||
|
const selectedTemplate = selectedTemplateId
|
||||||
|
? templatesQuery.data?.find(
|
||||||
|
(template) => template.Id === selectedTemplateId
|
||||||
|
)
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
const { disabledTypes, fixedCategories, tableKey } = useViewFilter(envId);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<PageHeader title="Application templates list" breadcrumbs="Templates" />
|
||||||
|
{selectedTemplate && (
|
||||||
|
<DeployForm
|
||||||
|
template={selectedTemplate}
|
||||||
|
unselect={() => setSelectedTemplateId()}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<AppTemplatesList
|
||||||
|
templates={templatesQuery.data}
|
||||||
|
selectedId={selectedTemplateId}
|
||||||
|
onSelect={
|
||||||
|
envId && hasCreateAuthQuery.authorized
|
||||||
|
? (template) => setSelectedTemplateId(template.Id)
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
disabledTypes={disabledTypes}
|
||||||
|
fixedCategories={fixedCategories}
|
||||||
|
storageKey={tableKey}
|
||||||
|
templateLinkParams={
|
||||||
|
!envId
|
||||||
|
? (template) => ({
|
||||||
|
to: 'edge.stacks.new',
|
||||||
|
params: { templateId: template.Id, templateType: 'app' },
|
||||||
|
})
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function useViewFilter(envId: number | undefined) {
|
||||||
|
const envInfoQuery = useInfo(envId);
|
||||||
|
const apiVersion = useApiVersion(envId);
|
||||||
|
|
||||||
|
if (!envId) {
|
||||||
|
// edge
|
||||||
|
return {
|
||||||
|
disabledTypes: [TemplateType.Container],
|
||||||
|
fixedCategories: ['edge'],
|
||||||
|
tableKey: 'edge-app-templates',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const showSwarmStacks =
|
||||||
|
apiVersion >= 1.25 &&
|
||||||
|
envInfoQuery.data &&
|
||||||
|
envInfoQuery.data.Swarm &&
|
||||||
|
envInfoQuery.data.Swarm.NodeID &&
|
||||||
|
envInfoQuery.data.Swarm.ControlAvailable;
|
||||||
|
|
||||||
|
return {
|
||||||
|
disabledTypes: !showSwarmStacks ? [TemplateType.SwarmStack] : [],
|
||||||
|
tableKey: 'docker-app-templates',
|
||||||
|
};
|
||||||
|
}
|
|
@ -0,0 +1,52 @@
|
||||||
|
import { Minus, Plus } from 'lucide-react';
|
||||||
|
import { PropsWithChildren, ReactNode, useState } from 'react';
|
||||||
|
|
||||||
|
import { Icon } from '@@/Icon';
|
||||||
|
import { Button } from '@@/buttons';
|
||||||
|
|
||||||
|
export function AdvancedSettings({
|
||||||
|
children,
|
||||||
|
label,
|
||||||
|
}: PropsWithChildren<{
|
||||||
|
label: (isOpen: boolean) => ReactNode;
|
||||||
|
}>) {
|
||||||
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<AdvancedSettingsToggle
|
||||||
|
isOpen={isOpen}
|
||||||
|
onClick={() => setIsOpen((value) => !value)}
|
||||||
|
label={label(isOpen)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{isOpen ? children : null}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function AdvancedSettingsToggle({
|
||||||
|
label,
|
||||||
|
onClick,
|
||||||
|
isOpen,
|
||||||
|
}: {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClick: () => void;
|
||||||
|
label: ReactNode;
|
||||||
|
}) {
|
||||||
|
const icon = isOpen ? Minus : Plus;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="form-group">
|
||||||
|
<div className="col-sm-12">
|
||||||
|
<Button
|
||||||
|
color="none"
|
||||||
|
onClick={() => onClick()}
|
||||||
|
data-cy="advanced-settings-toggle-button"
|
||||||
|
>
|
||||||
|
<Icon icon={icon} />
|
||||||
|
{label}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
|
@ -0,0 +1,168 @@
|
||||||
|
import { Formik, Form } from 'formik';
|
||||||
|
|
||||||
|
import { useEnvironmentId } from '@/react/hooks/useEnvironmentId';
|
||||||
|
import { useCurrentUser, useIsEdgeAdmin } from '@/react/hooks/useUser';
|
||||||
|
import { AccessControlForm } from '@/react/portainer/access-control';
|
||||||
|
import { parseAccessControlFormData } from '@/react/portainer/access-control/utils';
|
||||||
|
import { NameField } from '@/react/docker/containers/CreateView/BaseForm/NameField';
|
||||||
|
import { NetworkSelector } from '@/react/docker/containers/components/NetworkSelector';
|
||||||
|
import { PortsMappingField } from '@/react/docker/containers/CreateView/BaseForm/PortsMappingField';
|
||||||
|
import { VolumesTab } from '@/react/docker/containers/CreateView/VolumesTab';
|
||||||
|
import { HostsFileEntries } from '@/react/docker/containers/CreateView/NetworkTab/HostsFileEntries';
|
||||||
|
import { LabelsTab } from '@/react/docker/containers/CreateView/LabelsTab';
|
||||||
|
import { HostnameField } from '@/react/docker/containers/CreateView/NetworkTab/HostnameField';
|
||||||
|
|
||||||
|
import { FormControl } from '@@/form-components/FormControl';
|
||||||
|
import { FormSection } from '@@/form-components/FormSection';
|
||||||
|
import { FormActions } from '@@/form-components/FormActions';
|
||||||
|
import { Button } from '@@/buttons';
|
||||||
|
|
||||||
|
import { TemplateViewModel } from '../../view-model';
|
||||||
|
import { AdvancedSettings } from '../AdvancedSettings';
|
||||||
|
import { EnvVarsFieldset } from '../EnvVarsFieldset';
|
||||||
|
|
||||||
|
import { useValidation } from './useValidation';
|
||||||
|
import { FormValues } from './types';
|
||||||
|
import { useCreate } from './useCreate';
|
||||||
|
|
||||||
|
export function ContainerDeployForm({
|
||||||
|
template,
|
||||||
|
unselect,
|
||||||
|
}: {
|
||||||
|
template: TemplateViewModel;
|
||||||
|
unselect: () => void;
|
||||||
|
}) {
|
||||||
|
const { user } = useCurrentUser();
|
||||||
|
const isEdgeAdminQuery = useIsEdgeAdmin();
|
||||||
|
const environmentId = useEnvironmentId();
|
||||||
|
|
||||||
|
const validation = useValidation({
|
||||||
|
isAdmin: isEdgeAdminQuery.isAdmin,
|
||||||
|
envVarDefinitions: template.Env,
|
||||||
|
});
|
||||||
|
|
||||||
|
const createMutation = useCreate(template);
|
||||||
|
|
||||||
|
if (!createMutation || isEdgeAdminQuery.isLoading) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const initialValues: FormValues = {
|
||||||
|
name: template.Name || '',
|
||||||
|
envVars:
|
||||||
|
Object.fromEntries(template.Env?.map((env) => [env.name, env.value])) ||
|
||||||
|
{},
|
||||||
|
accessControl: parseAccessControlFormData(
|
||||||
|
isEdgeAdminQuery.isAdmin,
|
||||||
|
user.Id
|
||||||
|
),
|
||||||
|
hostname: '',
|
||||||
|
hosts: [],
|
||||||
|
labels: [],
|
||||||
|
network: '',
|
||||||
|
ports: template.Ports.map((p) => ({ ...p, hostPort: p.hostPort || '' })),
|
||||||
|
volumes: template.Volumes.map((v) => ({
|
||||||
|
containerPath: v.container,
|
||||||
|
type: v.type === 'bind' ? 'bind' : 'volume',
|
||||||
|
readOnly: v.readonly,
|
||||||
|
name: v.type === 'bind' ? v.bind || '' : 'auto',
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Formik
|
||||||
|
initialValues={initialValues}
|
||||||
|
onSubmit={createMutation.onSubmit}
|
||||||
|
validationSchema={validation}
|
||||||
|
validateOnMount
|
||||||
|
>
|
||||||
|
{({ values, errors, setFieldValue, isValid }) => (
|
||||||
|
<Form className="form-horizontal">
|
||||||
|
<FormSection title="Configuration">
|
||||||
|
<NameField
|
||||||
|
value={values.name}
|
||||||
|
onChange={(v) => setFieldValue('name', v)}
|
||||||
|
error={errors.name}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormControl label="Network" errors={errors?.network}>
|
||||||
|
<NetworkSelector
|
||||||
|
value={values.network}
|
||||||
|
onChange={(v) => setFieldValue('network', v)}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
|
||||||
|
<EnvVarsFieldset
|
||||||
|
values={values.envVars}
|
||||||
|
onChange={(values) => setFieldValue('envVars', values)}
|
||||||
|
errors={errors.envVars}
|
||||||
|
options={template.Env || []}
|
||||||
|
/>
|
||||||
|
</FormSection>
|
||||||
|
|
||||||
|
<AccessControlForm
|
||||||
|
formNamespace="accessControl"
|
||||||
|
onChange={(values) => setFieldValue('accessControl', values)}
|
||||||
|
values={values.accessControl}
|
||||||
|
errors={errors.accessControl}
|
||||||
|
environmentId={environmentId}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<AdvancedSettings
|
||||||
|
label={(isOpen) =>
|
||||||
|
isOpen ? 'Hide advanced options' : 'Show advanced options'
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<PortsMappingField
|
||||||
|
value={values.ports}
|
||||||
|
onChange={(v) => setFieldValue('ports', v)}
|
||||||
|
errors={errors.ports}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<VolumesTab
|
||||||
|
onChange={(v) => setFieldValue('volumes', v)}
|
||||||
|
values={values.volumes}
|
||||||
|
errors={errors.volumes}
|
||||||
|
allowAuto
|
||||||
|
/>
|
||||||
|
|
||||||
|
<HostsFileEntries
|
||||||
|
values={values.hosts}
|
||||||
|
onChange={(v) => setFieldValue('hosts', v)}
|
||||||
|
errors={errors?.hosts}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<LabelsTab
|
||||||
|
values={values.labels}
|
||||||
|
onChange={(v) => setFieldValue('labels', v)}
|
||||||
|
errors={errors?.labels}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<HostnameField
|
||||||
|
value={values.hostname}
|
||||||
|
onChange={(v) => setFieldValue('hostname', v)}
|
||||||
|
error={errors.hostname}
|
||||||
|
/>
|
||||||
|
</AdvancedSettings>
|
||||||
|
|
||||||
|
<FormActions
|
||||||
|
isLoading={createMutation.isLoading}
|
||||||
|
isValid={isValid}
|
||||||
|
loadingText="Deployment in progress..."
|
||||||
|
submitLabel="Deploy the container"
|
||||||
|
data-cy="deploy-container-button"
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
type="reset"
|
||||||
|
onClick={() => unselect()}
|
||||||
|
color="default"
|
||||||
|
data-cy="cancel-deploy-container-button"
|
||||||
|
>
|
||||||
|
Hide
|
||||||
|
</Button>
|
||||||
|
</FormActions>
|
||||||
|
</Form>
|
||||||
|
)}
|
||||||
|
</Formik>
|
||||||
|
);
|
||||||
|
}
|
|
@ -0,0 +1,64 @@
|
||||||
|
import { commandStringToArray } from '@/docker/helpers/containers';
|
||||||
|
import { parsePortBindingRequest } from '@/react/docker/containers/CreateView/BaseForm/PortsMappingField.requestModel';
|
||||||
|
import { volumesTabUtils } from '@/react/docker/containers/CreateView/VolumesTab';
|
||||||
|
import { CreateContainerRequest } from '@/react/docker/containers/CreateView/types';
|
||||||
|
|
||||||
|
import { TemplateViewModel } from '../../view-model';
|
||||||
|
|
||||||
|
import { FormValues } from './types';
|
||||||
|
|
||||||
|
export function createContainerConfiguration(
|
||||||
|
template: TemplateViewModel,
|
||||||
|
values: FormValues
|
||||||
|
): CreateContainerRequest {
|
||||||
|
let configuration: CreateContainerRequest = {
|
||||||
|
Env: [],
|
||||||
|
OpenStdin: false,
|
||||||
|
Tty: false,
|
||||||
|
ExposedPorts: {},
|
||||||
|
HostConfig: {
|
||||||
|
RestartPolicy: {
|
||||||
|
Name: 'no',
|
||||||
|
},
|
||||||
|
PortBindings: {},
|
||||||
|
Binds: [],
|
||||||
|
Privileged: false,
|
||||||
|
ExtraHosts: [],
|
||||||
|
},
|
||||||
|
Volumes: {},
|
||||||
|
Labels: {},
|
||||||
|
NetworkingConfig: {},
|
||||||
|
};
|
||||||
|
|
||||||
|
configuration = volumesTabUtils.toRequest(configuration, values.volumes);
|
||||||
|
|
||||||
|
configuration.HostConfig.NetworkMode = values.network;
|
||||||
|
configuration.HostConfig.Privileged = template.Privileged;
|
||||||
|
configuration.HostConfig.RestartPolicy = { Name: template.RestartPolicy };
|
||||||
|
configuration.HostConfig.ExtraHosts = values.hosts ? values.hosts : [];
|
||||||
|
configuration.Hostname = values.hostname;
|
||||||
|
configuration.Env = Object.entries(values.envVars).map(
|
||||||
|
([name, value]) => `${name}=${value}`
|
||||||
|
);
|
||||||
|
configuration.Cmd = commandStringToArray(template.Command);
|
||||||
|
const portBindings = parsePortBindingRequest(values.ports);
|
||||||
|
configuration.HostConfig.PortBindings = portBindings;
|
||||||
|
configuration.ExposedPorts = Object.fromEntries(
|
||||||
|
Object.keys(portBindings).map((key) => [key, {}])
|
||||||
|
);
|
||||||
|
const consoleConfiguration = getConsoleConfiguration(template.Interactive);
|
||||||
|
configuration.OpenStdin = consoleConfiguration.openStdin;
|
||||||
|
configuration.Tty = consoleConfiguration.tty;
|
||||||
|
configuration.Labels = Object.fromEntries(
|
||||||
|
values.labels.filter((l) => !!l.name).map((l) => [l.name, l.value])
|
||||||
|
);
|
||||||
|
configuration.Image = template.RegistryModel.Image;
|
||||||
|
return configuration;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getConsoleConfiguration(interactiveFlag: boolean) {
|
||||||
|
return {
|
||||||
|
openStdin: interactiveFlag,
|
||||||
|
tty: interactiveFlag,
|
||||||
|
};
|
||||||
|
}
|
|
@ -0,0 +1,18 @@
|
||||||
|
import { AccessControlFormData } from '@/react/portainer/access-control/types';
|
||||||
|
import { PortMapping } from '@/react/docker/containers/CreateView/BaseForm/PortsMappingField';
|
||||||
|
import { VolumesTabValues } from '@/react/docker/containers/CreateView/VolumesTab';
|
||||||
|
import { LabelsTabValues } from '@/react/docker/containers/CreateView/LabelsTab';
|
||||||
|
|
||||||
|
import { EnvVarsValue } from '../EnvVarsFieldset';
|
||||||
|
|
||||||
|
export interface FormValues {
|
||||||
|
name: string;
|
||||||
|
network: string;
|
||||||
|
accessControl: AccessControlFormData;
|
||||||
|
ports: Array<PortMapping>;
|
||||||
|
volumes: VolumesTabValues;
|
||||||
|
hosts: Array<string>;
|
||||||
|
labels: LabelsTabValues;
|
||||||
|
hostname: string;
|
||||||
|
envVars: EnvVarsValue;
|
||||||
|
}
|
|
@ -0,0 +1,68 @@
|
||||||
|
import { useRouter } from '@uirouter/react';
|
||||||
|
|
||||||
|
import { notifySuccess } from '@/portainer/services/notifications';
|
||||||
|
import { useCurrentEnvironment } from '@/react/hooks/useCurrentEnvironment';
|
||||||
|
import { useCreateOrReplaceMutation } from '@/react/docker/containers/CreateView/useCreateMutation';
|
||||||
|
|
||||||
|
import { TemplateViewModel } from '../../view-model';
|
||||||
|
|
||||||
|
import { FormValues } from './types';
|
||||||
|
import { createContainerConfiguration } from './createContainerConfig';
|
||||||
|
import { useCreateLocalVolumes } from './useCreateLocalVolumes';
|
||||||
|
|
||||||
|
export function useCreate(template: TemplateViewModel) {
|
||||||
|
const router = useRouter();
|
||||||
|
const createVolumesMutation = useCreateLocalVolumes();
|
||||||
|
const createContainerMutation = useCreateOrReplaceMutation();
|
||||||
|
const environmentQuery = useCurrentEnvironment();
|
||||||
|
|
||||||
|
if (!environmentQuery.data) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const environment = environmentQuery.data;
|
||||||
|
|
||||||
|
return {
|
||||||
|
onSubmit,
|
||||||
|
isLoading:
|
||||||
|
createVolumesMutation.isLoading || createContainerMutation.isLoading,
|
||||||
|
};
|
||||||
|
|
||||||
|
function onSubmit(values: FormValues) {
|
||||||
|
const autoVolumesCount = values.volumes.filter(
|
||||||
|
(v) => v.type === 'volume' && v.name === 'auto'
|
||||||
|
).length;
|
||||||
|
createVolumesMutation.mutate(autoVolumesCount, {
|
||||||
|
onSuccess(autoVolumes) {
|
||||||
|
let index = 0;
|
||||||
|
const volumes = values.volumes.map((v) =>
|
||||||
|
v.type === 'volume' && v.name === 'auto'
|
||||||
|
? { ...v, name: autoVolumes[index++].Name }
|
||||||
|
: v
|
||||||
|
);
|
||||||
|
|
||||||
|
createContainerMutation.mutate(
|
||||||
|
{
|
||||||
|
config: createContainerConfiguration(template, {
|
||||||
|
...values,
|
||||||
|
volumes,
|
||||||
|
}),
|
||||||
|
values: {
|
||||||
|
name: values.name,
|
||||||
|
accessControl: values.accessControl,
|
||||||
|
imageName: template.RegistryModel.Image,
|
||||||
|
alwaysPull: true,
|
||||||
|
},
|
||||||
|
environment,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
onSuccess() {
|
||||||
|
notifySuccess('Success', 'Container successfully created');
|
||||||
|
router.stateService.go('docker.containers');
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,16 @@
|
||||||
|
import { useMutation } from 'react-query';
|
||||||
|
|
||||||
|
import { useEnvironmentId } from '@/react/hooks/useEnvironmentId';
|
||||||
|
import { createVolume } from '@/react/docker/volumes/queries/useCreateVolume';
|
||||||
|
|
||||||
|
export function useCreateLocalVolumes() {
|
||||||
|
const environmentId = useEnvironmentId();
|
||||||
|
|
||||||
|
return useMutation(async (count: number) =>
|
||||||
|
Promise.all(
|
||||||
|
Array.from({ length: count }).map(() =>
|
||||||
|
createVolume(environmentId, { Driver: 'local' })
|
||||||
|
)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
|
@ -0,0 +1,37 @@
|
||||||
|
import { object, string } from 'yup';
|
||||||
|
import { useMemo } from 'react';
|
||||||
|
|
||||||
|
import { accessControlFormValidation } from '@/react/portainer/access-control/AccessControlForm';
|
||||||
|
import { hostnameSchema } from '@/react/docker/containers/CreateView/NetworkTab/HostnameField';
|
||||||
|
import { hostFileSchema } from '@/react/docker/containers/CreateView/NetworkTab/HostsFileEntries';
|
||||||
|
import { labelsTabUtils } from '@/react/docker/containers/CreateView/LabelsTab';
|
||||||
|
import { nameValidation } from '@/react/docker/containers/CreateView/BaseForm/NameField';
|
||||||
|
import { validationSchema as portSchema } from '@/react/docker/containers/CreateView/BaseForm/PortsMappingField.validation';
|
||||||
|
import { volumesTabUtils } from '@/react/docker/containers/CreateView/VolumesTab';
|
||||||
|
|
||||||
|
import { envVarsFieldsetValidation } from '../EnvVarsFieldset';
|
||||||
|
import { TemplateEnv } from '../../types';
|
||||||
|
|
||||||
|
export function useValidation({
|
||||||
|
isAdmin,
|
||||||
|
envVarDefinitions,
|
||||||
|
}: {
|
||||||
|
isAdmin: boolean;
|
||||||
|
envVarDefinitions: Array<TemplateEnv>;
|
||||||
|
}) {
|
||||||
|
return useMemo(
|
||||||
|
() =>
|
||||||
|
object({
|
||||||
|
accessControl: accessControlFormValidation(isAdmin),
|
||||||
|
envVars: envVarsFieldsetValidation(envVarDefinitions),
|
||||||
|
hostname: hostnameSchema,
|
||||||
|
hosts: hostFileSchema,
|
||||||
|
labels: labelsTabUtils.validation(),
|
||||||
|
name: nameValidation(),
|
||||||
|
network: string().default(''),
|
||||||
|
ports: portSchema(),
|
||||||
|
volumes: volumesTabUtils.validation(),
|
||||||
|
}),
|
||||||
|
[envVarDefinitions, isAdmin]
|
||||||
|
);
|
||||||
|
}
|
|
@ -0,0 +1,42 @@
|
||||||
|
import { useEnvironmentId } from '@/react/hooks/useEnvironmentId';
|
||||||
|
import { TemplateType } from '@/react/portainer/templates/app-templates/types';
|
||||||
|
import { TemplateViewModel } from '@/react/portainer/templates/app-templates/view-model';
|
||||||
|
import { DeployWidget } from '@/react/portainer/templates/components/DeployWidget';
|
||||||
|
|
||||||
|
import { ContainerDeployForm } from './ContainerDeployForm/ContainerDeployForm';
|
||||||
|
import { StackDeployForm } from './StackDeployForm/StackDeployForm';
|
||||||
|
|
||||||
|
export function DeployForm({
|
||||||
|
template,
|
||||||
|
unselect,
|
||||||
|
}: {
|
||||||
|
template: TemplateViewModel;
|
||||||
|
unselect: () => void;
|
||||||
|
}) {
|
||||||
|
const Form = useForm(template);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DeployWidget
|
||||||
|
logo={template.Logo}
|
||||||
|
note={template.Note}
|
||||||
|
title={template.Title}
|
||||||
|
>
|
||||||
|
<Form template={template} unselect={unselect} />
|
||||||
|
</DeployWidget>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function useForm(template: TemplateViewModel) {
|
||||||
|
const envId = useEnvironmentId(false);
|
||||||
|
|
||||||
|
if (!envId) {
|
||||||
|
// for edge templates, return empty form
|
||||||
|
return () => null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (template.Type === TemplateType.Container) {
|
||||||
|
return ContainerDeployForm;
|
||||||
|
}
|
||||||
|
|
||||||
|
return StackDeployForm;
|
||||||
|
}
|
|
@ -15,14 +15,13 @@ test('renders EnvVarsFieldset component', () => {
|
||||||
{ name: 'VAR2', label: 'Variable 2', preset: false },
|
{ name: 'VAR2', label: 'Variable 2', preset: false },
|
||||||
] as const;
|
] as const;
|
||||||
const value = { VAR1: 'Value 1', VAR2: 'Value 2' };
|
const value = { VAR1: 'Value 1', VAR2: 'Value 2' };
|
||||||
const errors = {};
|
|
||||||
|
|
||||||
render(
|
render(
|
||||||
<EnvVarsFieldset
|
<EnvVarsFieldset
|
||||||
onChange={onChange}
|
onChange={onChange}
|
||||||
options={[...options]}
|
options={[...options]}
|
||||||
value={value}
|
values={value}
|
||||||
errors={errors}
|
errors={{}}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
@ -40,14 +39,13 @@ test('calls onChange when input value changes', async () => {
|
||||||
const onChange = vi.fn();
|
const onChange = vi.fn();
|
||||||
const options = [{ name: 'VAR1', label: 'Variable 1', preset: false }];
|
const options = [{ name: 'VAR1', label: 'Variable 1', preset: false }];
|
||||||
const value = { VAR1: 'Value 1' };
|
const value = { VAR1: 'Value 1' };
|
||||||
const errors = {};
|
|
||||||
|
|
||||||
render(
|
render(
|
||||||
<EnvVarsFieldset
|
<EnvVarsFieldset
|
||||||
onChange={onChange}
|
onChange={onChange}
|
||||||
options={options}
|
options={options}
|
||||||
value={value}
|
values={value}
|
||||||
errors={errors}
|
errors={{}}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
@ -63,15 +61,14 @@ test('calls onChange when input value changes', async () => {
|
||||||
test('renders error message when there are errors', () => {
|
test('renders error message when there are errors', () => {
|
||||||
const onChange = vi.fn();
|
const onChange = vi.fn();
|
||||||
const options = [{ name: 'VAR1', label: 'Variable 1', preset: false }];
|
const options = [{ name: 'VAR1', label: 'Variable 1', preset: false }];
|
||||||
const value = { VAR1: 'Value 1' };
|
const value = { VAR1: '' };
|
||||||
const errors = { VAR1: 'Required' };
|
|
||||||
|
|
||||||
render(
|
render(
|
||||||
<EnvVarsFieldset
|
<EnvVarsFieldset
|
||||||
onChange={onChange}
|
onChange={onChange}
|
||||||
options={options}
|
options={options}
|
||||||
value={value}
|
values={value}
|
||||||
errors={errors}
|
errors={{ VAR1: 'Required' }}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
@ -104,7 +101,10 @@ test('returns default values', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
test('validates env vars fieldset', () => {
|
test('validates env vars fieldset', () => {
|
||||||
const schema = envVarsFieldsetValidation();
|
const schema = envVarsFieldsetValidation([
|
||||||
|
{ name: 'VAR1' },
|
||||||
|
{ name: 'VAR2' },
|
||||||
|
]);
|
||||||
|
|
||||||
const validData = { VAR1: 'Value 1', VAR2: 'Value 2' };
|
const validData = { VAR1: 'Value 1', VAR2: 'Value 2' };
|
||||||
const invalidData = { VAR1: '', VAR2: 'Value 2' };
|
const invalidData = { VAR1: '', VAR2: 'Value 2' };
|
|
@ -1,5 +1,5 @@
|
||||||
import { FormikErrors } from 'formik';
|
import { FormikErrors } from 'formik';
|
||||||
import { SchemaOf, array, string } from 'yup';
|
import { SchemaOf, object, string } from 'yup';
|
||||||
|
|
||||||
import { TemplateEnv } from '@/react/portainer/templates/app-templates/types';
|
import { TemplateEnv } from '@/react/portainer/templates/app-templates/types';
|
||||||
|
|
||||||
|
@ -8,15 +8,17 @@ import { Input, Select } from '@@/form-components/Input';
|
||||||
|
|
||||||
type Value = Record<string, string>;
|
type Value = Record<string, string>;
|
||||||
|
|
||||||
|
export { type Value as EnvVarsValue };
|
||||||
|
|
||||||
export function EnvVarsFieldset({
|
export function EnvVarsFieldset({
|
||||||
onChange,
|
onChange,
|
||||||
options,
|
options,
|
||||||
value,
|
values,
|
||||||
errors,
|
errors,
|
||||||
}: {
|
}: {
|
||||||
options: Array<TemplateEnv>;
|
options: Array<TemplateEnv>;
|
||||||
onChange: (value: Value) => void;
|
onChange: (value: Value) => void;
|
||||||
value: Value;
|
values: Value;
|
||||||
errors?: FormikErrors<Value>;
|
errors?: FormikErrors<Value>;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
|
@ -25,7 +27,7 @@ export function EnvVarsFieldset({
|
||||||
<Item
|
<Item
|
||||||
key={env.name}
|
key={env.name}
|
||||||
option={env}
|
option={env}
|
||||||
value={value[env.name]}
|
value={values[env.name]}
|
||||||
onChange={(value) => handleChange(env.name, value)}
|
onChange={(value) => handleChange(env.name, value)}
|
||||||
errors={errors?.[env.name]}
|
errors={errors?.[env.name]}
|
||||||
/>
|
/>
|
||||||
|
@ -34,7 +36,7 @@ export function EnvVarsFieldset({
|
||||||
);
|
);
|
||||||
|
|
||||||
function handleChange(name: string, envValue: string) {
|
function handleChange(name: string, envValue: string) {
|
||||||
onChange({ ...value, [name]: envValue });
|
onChange({ ...values, [name]: envValue });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -94,11 +96,12 @@ export function getDefaultValues(definitions: Array<TemplateEnv>): Value {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function envVarsFieldsetValidation(): SchemaOf<Value> {
|
export function envVarsFieldsetValidation(
|
||||||
return (
|
definitions: Array<TemplateEnv>
|
||||||
array()
|
): SchemaOf<Value> {
|
||||||
.transform((_, orig) => Object.values(orig))
|
return object(
|
||||||
// casting to return the correct type - validation works as expected
|
Object.fromEntries(
|
||||||
.of(string().required('Required')) as unknown as SchemaOf<Value>
|
definitions.map((v) => [v.name, string().required('Required')])
|
||||||
|
)
|
||||||
);
|
);
|
||||||
}
|
}
|
|
@ -0,0 +1,163 @@
|
||||||
|
import { useRouter } from '@uirouter/react';
|
||||||
|
import { Formik, Form } from 'formik';
|
||||||
|
|
||||||
|
import { notifySuccess } from '@/portainer/services/notifications';
|
||||||
|
import {
|
||||||
|
SwarmCreatePayload,
|
||||||
|
useCreateStack,
|
||||||
|
} from '@/react/common/stacks/queries/useCreateStack/useCreateStack';
|
||||||
|
import { useEnvironmentId } from '@/react/hooks/useEnvironmentId';
|
||||||
|
import { useCurrentUser, useIsEdgeAdmin } from '@/react/hooks/useUser';
|
||||||
|
import { AccessControlForm } from '@/react/portainer/access-control';
|
||||||
|
import { parseAccessControlFormData } from '@/react/portainer/access-control/utils';
|
||||||
|
import { TemplateType } from '@/react/portainer/templates/app-templates/types';
|
||||||
|
import { TemplateViewModel } from '@/react/portainer/templates/app-templates/view-model';
|
||||||
|
import { NameField } from '@/react/common/stacks/CreateView/NameField';
|
||||||
|
import { useSwarmId } from '@/react/docker/proxy/queries/useSwarm';
|
||||||
|
|
||||||
|
import { Button } from '@@/buttons';
|
||||||
|
import { FormActions } from '@@/form-components/FormActions';
|
||||||
|
import { FormSection } from '@@/form-components/FormSection';
|
||||||
|
import { TextTip } from '@@/Tip/TextTip';
|
||||||
|
|
||||||
|
import { EnvVarsFieldset } from '../EnvVarsFieldset';
|
||||||
|
|
||||||
|
import { FormValues } from './types';
|
||||||
|
import { useValidation } from './useValidation';
|
||||||
|
import { useIsDeployable } from './useIsDeployable';
|
||||||
|
|
||||||
|
export function StackDeployForm({
|
||||||
|
template,
|
||||||
|
unselect,
|
||||||
|
}: {
|
||||||
|
template: TemplateViewModel;
|
||||||
|
unselect: () => void;
|
||||||
|
}) {
|
||||||
|
const isDeployable = useIsDeployable(template.Type);
|
||||||
|
|
||||||
|
const router = useRouter();
|
||||||
|
const isEdgeAdminQuery = useIsEdgeAdmin();
|
||||||
|
|
||||||
|
const { user } = useCurrentUser();
|
||||||
|
const environmentId = useEnvironmentId();
|
||||||
|
const swarmIdQuery = useSwarmId(environmentId);
|
||||||
|
const mutation = useCreateStack();
|
||||||
|
const validation = useValidation({
|
||||||
|
isAdmin: isEdgeAdminQuery.isAdmin,
|
||||||
|
environmentId,
|
||||||
|
envVarDefinitions: template.Env,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (isEdgeAdminQuery.isLoading) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const initialValues: FormValues = {
|
||||||
|
name: template.Name || '',
|
||||||
|
envVars:
|
||||||
|
Object.fromEntries(template.Env?.map((env) => [env.name, env.value])) ||
|
||||||
|
{},
|
||||||
|
accessControl: parseAccessControlFormData(
|
||||||
|
isEdgeAdminQuery.isAdmin,
|
||||||
|
user.Id
|
||||||
|
),
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!isDeployable) {
|
||||||
|
return (
|
||||||
|
<div className="form-group">
|
||||||
|
<TextTip>
|
||||||
|
This template type cannot be deployed on this environment.
|
||||||
|
</TextTip>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Formik
|
||||||
|
initialValues={initialValues}
|
||||||
|
onSubmit={handleSubmit}
|
||||||
|
validationSchema={validation}
|
||||||
|
validateOnMount
|
||||||
|
>
|
||||||
|
{({ values, errors, setFieldValue, isValid }) => (
|
||||||
|
<Form className="form-horizontal">
|
||||||
|
<FormSection title="Configuration">
|
||||||
|
<NameField
|
||||||
|
value={values.name}
|
||||||
|
onChange={(v) => setFieldValue('name', v)}
|
||||||
|
errors={errors.name}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<EnvVarsFieldset
|
||||||
|
values={values.envVars}
|
||||||
|
onChange={(values) => setFieldValue('envVars', values)}
|
||||||
|
errors={errors.envVars}
|
||||||
|
options={template.Env || []}
|
||||||
|
/>
|
||||||
|
</FormSection>
|
||||||
|
|
||||||
|
<AccessControlForm
|
||||||
|
formNamespace="accessControl"
|
||||||
|
onChange={(values) => setFieldValue('accessControl', values)}
|
||||||
|
values={values.accessControl}
|
||||||
|
errors={errors.accessControl}
|
||||||
|
environmentId={environmentId}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormActions
|
||||||
|
isLoading={mutation.isLoading}
|
||||||
|
isValid={isValid}
|
||||||
|
loadingText="Deployment in progress..."
|
||||||
|
submitLabel="Deploy the stack"
|
||||||
|
data-cy="deploy-stack-button"
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
type="reset"
|
||||||
|
onClick={() => unselect()}
|
||||||
|
color="default"
|
||||||
|
data-cy="cancel-deploy-stack-button"
|
||||||
|
>
|
||||||
|
Hide
|
||||||
|
</Button>
|
||||||
|
</FormActions>
|
||||||
|
</Form>
|
||||||
|
)}
|
||||||
|
</Formik>
|
||||||
|
);
|
||||||
|
|
||||||
|
function handleSubmit(values: FormValues) {
|
||||||
|
const type =
|
||||||
|
template.Type === TemplateType.ComposeStack ? 'standalone' : 'swarm';
|
||||||
|
const payload: SwarmCreatePayload['payload'] = {
|
||||||
|
name: values.name,
|
||||||
|
environmentId,
|
||||||
|
|
||||||
|
env: Object.entries(values.envVars).map(([name, value]) => ({
|
||||||
|
name,
|
||||||
|
value,
|
||||||
|
})),
|
||||||
|
swarmId: swarmIdQuery.data || '',
|
||||||
|
git: {
|
||||||
|
RepositoryURL: template.Repository.url,
|
||||||
|
ComposeFilePathInRepository: template.Repository.stackfile,
|
||||||
|
},
|
||||||
|
fromAppTemplate: true,
|
||||||
|
accessControl: values.accessControl,
|
||||||
|
};
|
||||||
|
|
||||||
|
return mutation.mutate(
|
||||||
|
{
|
||||||
|
type,
|
||||||
|
method: 'git',
|
||||||
|
payload,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
onSuccess() {
|
||||||
|
notifySuccess('Success', 'Stack created');
|
||||||
|
router.stateService.go('docker.stacks');
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,7 @@
|
||||||
|
import { AccessControlFormData } from '@/react/portainer/access-control/types';
|
||||||
|
|
||||||
|
export interface FormValues {
|
||||||
|
name: string;
|
||||||
|
envVars: Record<string, string>;
|
||||||
|
accessControl: AccessControlFormData;
|
||||||
|
}
|
|
@ -0,0 +1,19 @@
|
||||||
|
import { useEnvironmentId } from '@/react/hooks/useEnvironmentId';
|
||||||
|
import { TemplateType } from '@/react/portainer/templates/app-templates/types';
|
||||||
|
import { useIsSwarm } from '@/react/docker/proxy/queries/useInfo';
|
||||||
|
|
||||||
|
export function useIsDeployable(type: TemplateType) {
|
||||||
|
const environmentId = useEnvironmentId();
|
||||||
|
|
||||||
|
const isSwarm = useIsSwarm(environmentId);
|
||||||
|
|
||||||
|
switch (type) {
|
||||||
|
case TemplateType.ComposeStack:
|
||||||
|
case TemplateType.Container:
|
||||||
|
return true;
|
||||||
|
case TemplateType.SwarmStack:
|
||||||
|
return isSwarm;
|
||||||
|
default:
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,33 @@
|
||||||
|
import { useMemo } from 'react';
|
||||||
|
import { SchemaOf, object } from 'yup';
|
||||||
|
|
||||||
|
import { accessControlFormValidation } from '@/react/portainer/access-control/AccessControlForm';
|
||||||
|
import { useNameValidation } from '@/react/common/stacks/CreateView/NameField';
|
||||||
|
import { EnvironmentId } from '@/react/portainer/environments/types';
|
||||||
|
|
||||||
|
import { envVarsFieldsetValidation } from '../EnvVarsFieldset';
|
||||||
|
import { TemplateEnv } from '../../types';
|
||||||
|
|
||||||
|
import { FormValues } from './types';
|
||||||
|
|
||||||
|
export function useValidation({
|
||||||
|
environmentId,
|
||||||
|
isAdmin,
|
||||||
|
envVarDefinitions,
|
||||||
|
}: {
|
||||||
|
isAdmin: boolean;
|
||||||
|
environmentId: EnvironmentId;
|
||||||
|
envVarDefinitions: Array<TemplateEnv>;
|
||||||
|
}): SchemaOf<FormValues> {
|
||||||
|
const name = useNameValidation(environmentId);
|
||||||
|
|
||||||
|
return useMemo(
|
||||||
|
() =>
|
||||||
|
object({
|
||||||
|
name,
|
||||||
|
accessControl: accessControlFormValidation(isAdmin),
|
||||||
|
envVars: envVarsFieldsetValidation(envVarDefinitions),
|
||||||
|
}),
|
||||||
|
[envVarDefinitions, isAdmin, name]
|
||||||
|
);
|
||||||
|
}
|
|
@ -1,3 +1,5 @@
|
||||||
|
import { RestartPolicy } from 'docker-types/generated/1.41';
|
||||||
|
|
||||||
import { BasicTableSettings } from '@@/datatables/types';
|
import { BasicTableSettings } from '@@/datatables/types';
|
||||||
|
|
||||||
import { Pair } from '../../settings/types';
|
import { Pair } from '../../settings/types';
|
||||||
|
@ -152,7 +154,7 @@ export interface AppTemplate {
|
||||||
* Container restart policy.
|
* Container restart policy.
|
||||||
* @example "on-failure"
|
* @example "on-failure"
|
||||||
*/
|
*/
|
||||||
restart_policy?: string;
|
restart_policy?: RestartPolicy['Name'];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Container hostname.
|
* Container hostname.
|
||||||
|
@ -181,7 +183,7 @@ export interface TemplateRepository {
|
||||||
/**
|
/**
|
||||||
* TemplateVolume represents a template volume configuration.
|
* TemplateVolume represents a template volume configuration.
|
||||||
*/
|
*/
|
||||||
interface TemplateVolume {
|
export interface TemplateVolume {
|
||||||
/**
|
/**
|
||||||
* Path inside the container.
|
* Path inside the container.
|
||||||
* @example "/data"
|
* @example "/data"
|
||||||
|
|
|
@ -1,4 +1,5 @@
|
||||||
import _ from 'lodash';
|
import _ from 'lodash';
|
||||||
|
import { RestartPolicy } from 'docker-types/generated/1.41';
|
||||||
|
|
||||||
import { PorImageRegistryModel } from 'Docker/models/porImageRegistry';
|
import { PorImageRegistryModel } from 'Docker/models/porImageRegistry';
|
||||||
|
|
||||||
|
@ -47,7 +48,7 @@ export class TemplateViewModel {
|
||||||
|
|
||||||
Interactive!: boolean;
|
Interactive!: boolean;
|
||||||
|
|
||||||
RestartPolicy!: string;
|
RestartPolicy!: RestartPolicy['Name'];
|
||||||
|
|
||||||
Hosts!: string[];
|
Hosts!: string[];
|
||||||
|
|
||||||
|
@ -58,14 +59,14 @@ export class TemplateViewModel {
|
||||||
Volumes!: {
|
Volumes!: {
|
||||||
container: string;
|
container: string;
|
||||||
readonly: boolean;
|
readonly: boolean;
|
||||||
type: string;
|
type: 'bind' | 'auto';
|
||||||
bind: string | null;
|
bind: string | null;
|
||||||
}[];
|
}[];
|
||||||
|
|
||||||
Ports!: {
|
Ports!: {
|
||||||
hostPort: string | undefined;
|
hostPort: string | undefined;
|
||||||
containerPort: string;
|
containerPort: string;
|
||||||
protocol: string;
|
protocol: 'tcp' | 'udp';
|
||||||
}[];
|
}[];
|
||||||
|
|
||||||
constructor(template: AppTemplate, version: string) {
|
constructor(template: AppTemplate, version: string) {
|
||||||
|
@ -134,7 +135,7 @@ function templatePorts(data: AppTemplate) {
|
||||||
hostAndContainerPort.length > 1
|
hostAndContainerPort.length > 1
|
||||||
? hostAndContainerPort[1]
|
? hostAndContainerPort[1]
|
||||||
: hostAndContainerPort[0],
|
: hostAndContainerPort[0],
|
||||||
protocol: portAndProtocol[1],
|
protocol: portAndProtocol[1] as 'tcp' | 'udp',
|
||||||
};
|
};
|
||||||
}) || []
|
}) || []
|
||||||
);
|
);
|
||||||
|
@ -145,7 +146,7 @@ function templateVolumes(data: AppTemplate) {
|
||||||
data.volumes?.map((v) => ({
|
data.volumes?.map((v) => ({
|
||||||
container: v.container,
|
container: v.container,
|
||||||
readonly: v.readonly || false,
|
readonly: v.readonly || false,
|
||||||
type: v.bind ? 'bind' : 'auto',
|
type: (v.bind ? 'bind' : 'auto') as 'bind' | 'auto',
|
||||||
bind: v.bind ? v.bind : null,
|
bind: v.bind ? v.bind : null,
|
||||||
})) || []
|
})) || []
|
||||||
);
|
);
|
||||||
|
|
|
@ -0,0 +1,40 @@
|
||||||
|
import { Rocket } from 'lucide-react';
|
||||||
|
import { PropsWithChildren } from 'react';
|
||||||
|
|
||||||
|
import { FallbackImage } from '@@/FallbackImage';
|
||||||
|
import { Icon } from '@@/Icon';
|
||||||
|
import { Widget } from '@@/Widget';
|
||||||
|
|
||||||
|
import { TemplateNote } from './TemplateNote';
|
||||||
|
|
||||||
|
export function DeployWidget({
|
||||||
|
logo,
|
||||||
|
note,
|
||||||
|
title,
|
||||||
|
children,
|
||||||
|
}: PropsWithChildren<{
|
||||||
|
logo?: string;
|
||||||
|
note?: string;
|
||||||
|
title: string;
|
||||||
|
}>) {
|
||||||
|
return (
|
||||||
|
<div className="row">
|
||||||
|
<div className="col-sm-12">
|
||||||
|
<Widget>
|
||||||
|
<Widget.Title
|
||||||
|
icon={
|
||||||
|
<FallbackImage src={logo} fallbackIcon={<Icon icon={Rocket} />} />
|
||||||
|
}
|
||||||
|
title={title}
|
||||||
|
/>
|
||||||
|
<Widget.Body>
|
||||||
|
<div className="form-horizontal">
|
||||||
|
<TemplateNote note={note} />
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</Widget.Body>
|
||||||
|
</Widget>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
|
@ -1,16 +1,18 @@
|
||||||
import sanitize from 'sanitize-html';
|
import sanitize from 'sanitize-html';
|
||||||
|
|
||||||
export function TemplateNote({ note }: { note: string | undefined }) {
|
import { FormSection } from '@@/form-components/FormSection';
|
||||||
|
|
||||||
|
export function TemplateNote({ note }: { note?: string }) {
|
||||||
if (!note) {
|
if (!note) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<FormSection title="Information">
|
||||||
<div className="col-sm-12 form-section-title"> Information </div>
|
|
||||||
<div className="form-group">
|
<div className="form-group">
|
||||||
<div className="col-sm-12">
|
<div className="col-sm-12">
|
||||||
<div
|
<div
|
||||||
className="template-note"
|
className="text-xs"
|
||||||
// eslint-disable-next-line react/no-danger
|
// eslint-disable-next-line react/no-danger
|
||||||
dangerouslySetInnerHTML={{
|
dangerouslySetInnerHTML={{
|
||||||
__html: sanitize(note),
|
__html: sanitize(note),
|
||||||
|
@ -18,6 +20,6 @@ export function TemplateNote({ note }: { note: string | undefined }) {
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</FormSection>
|
||||||
);
|
);
|
||||||
}
|
}
|
|
@ -17,9 +17,11 @@ import { InnerForm } from './InnerForm';
|
||||||
export function CreateForm({
|
export function CreateForm({
|
||||||
environmentId,
|
environmentId,
|
||||||
viewType,
|
viewType,
|
||||||
|
defaultType,
|
||||||
}: {
|
}: {
|
||||||
environmentId?: EnvironmentId;
|
environmentId?: EnvironmentId;
|
||||||
viewType: 'kube' | 'docker' | 'edge';
|
viewType: 'kube' | 'docker' | 'edge';
|
||||||
|
defaultType: StackType;
|
||||||
}) {
|
}) {
|
||||||
const isEdge = !environmentId;
|
const isEdge = !environmentId;
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
@ -28,8 +30,7 @@ export function CreateForm({
|
||||||
const buildMethods = useBuildMethods();
|
const buildMethods = useBuildMethods();
|
||||||
|
|
||||||
const initialValues = useInitialValues({
|
const initialValues = useInitialValues({
|
||||||
defaultType:
|
defaultType,
|
||||||
viewType === 'kube' ? StackType.Kubernetes : StackType.DockerCompose,
|
|
||||||
isEdge,
|
isEdge,
|
||||||
buildMethods: buildMethods.map((method) => method.value),
|
buildMethods: buildMethods.map((method) => method.value),
|
||||||
});
|
});
|
||||||
|
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue