mirror of https://github.com/portainer/portainer
refactor(ui/box-selector): replace all selectors [EE-3856] (#7902)
parent
c9253319d9
commit
2dddc1c6b9
|
@ -29,6 +29,23 @@ module.exports = {
|
|||
extensions: config.resolve.extensions,
|
||||
}),
|
||||
];
|
||||
|
||||
const svgRule = config.module.rules.find((rule) => rule.test && typeof rule.test.test === 'function' && rule.test.test('.svg'));
|
||||
svgRule.test = new RegExp(svgRule.test.source.replace('svg|', ''));
|
||||
|
||||
config.module.rules.unshift({
|
||||
test: /\.svg$/i,
|
||||
type: 'asset',
|
||||
resourceQuery: { not: [/c/] }, // exclude react component if *.svg?url
|
||||
});
|
||||
|
||||
config.module.rules.unshift({
|
||||
test: /\.svg$/i,
|
||||
issuer: /\.(js|ts)(x)?$/,
|
||||
resourceQuery: /c/, // *.svg?c
|
||||
use: [{ loader: '@svgr/webpack', options: { icon: true } }],
|
||||
});
|
||||
|
||||
return config;
|
||||
},
|
||||
core: {
|
||||
|
|
|
@ -112,7 +112,6 @@
|
|||
--bg-input-group-addon-color: var(--ui-gray-3);
|
||||
--bg-btn-default-color: var(--ui-blue-10);
|
||||
--bg-blocklist-hover-color: var(--ui-blue-2);
|
||||
--bg-boxselector-color: var(--ui-gray-2);
|
||||
--bg-table-color: var(--white-color);
|
||||
--bg-md-checkbox-color: var(--grey-12);
|
||||
--bg-form-control-disabled-color: var(--grey-11);
|
||||
|
@ -152,7 +151,6 @@
|
|||
--bg-input-autofill-color: var(--bg-inputbox);
|
||||
--bg-btn-default-hover-color: var(--ui-blue-9);
|
||||
--bg-btn-focus: var(--grey-59);
|
||||
--bg-boxselector-disabled-color: var(--white-color);
|
||||
--bg-small-select-color: var(--white-color);
|
||||
--bg-stepper-item-active: var(--white-color);
|
||||
--bg-stepper-item-counter: var(--grey-61);
|
||||
|
@ -214,8 +212,6 @@
|
|||
--text-button-group-color: var(--ui-gray-9);
|
||||
--text-button-dangerlight-color: var(--ui-error-5);
|
||||
--text-stepper-active-color: var(--ui-blue-8);
|
||||
--text-boxselector-header: var(--ui-black);
|
||||
|
||||
--border-color: var(--grey-42);
|
||||
--border-widget-color: var(--grey-43);
|
||||
--border-sidebar-color: var(--ui-blue-9);
|
||||
|
@ -225,7 +221,6 @@
|
|||
--border-datatable-top-color: var(--grey-10);
|
||||
--border-input-group-addon-color: var(--grey-44);
|
||||
--border-btn-default-color: var(--grey-44);
|
||||
--border-boxselector-color: var(--grey-6);
|
||||
--border-md-checkbox-color: var(--grey-19);
|
||||
--border-modal-header-color: var(--grey-45);
|
||||
--border-navtabs-color: var(--ui-white);
|
||||
|
@ -282,7 +277,6 @@
|
|||
--bg-body-color: var(--grey-2);
|
||||
--bg-btn-default-color: var(--grey-3);
|
||||
--bg-blocklist-hover-color: var(--ui-gray-iron-10);
|
||||
--bg-boxselector-color: var(--ui-gray-iron-10);
|
||||
--bg-blocklist-item-selected-color: var(--grey-3);
|
||||
--bg-card-color: var(--grey-1);
|
||||
--bg-checkbox-border-color: var(--grey-8);
|
||||
|
@ -337,7 +331,6 @@
|
|||
--bg-input-autofill-color: var(--bg-inputbox);
|
||||
--bg-btn-default-hover-color: var(--grey-4);
|
||||
--bg-btn-focus: var(--grey-3);
|
||||
--bg-boxselector-disabled-color: var(--grey-54);
|
||||
--bg-small-select-color: var(--grey-2);
|
||||
--bg-stepper-item-active: var(--grey-1);
|
||||
--bg-stepper-item-counter: var(--grey-7);
|
||||
|
@ -385,7 +378,6 @@
|
|||
--text-pagination-span-color: var(--ui-white);
|
||||
--text-pagination-span-hover-color: var(--ui-white);
|
||||
--text-summary-color: var(--white-color);
|
||||
--text-boxselector-wrapper-color: var(--white-color);
|
||||
--text-tooltip-color: var(--white-color);
|
||||
--text-rzslider-color: var(--white-color);
|
||||
--text-rzslider-limit-color: var(--white-color);
|
||||
|
@ -399,8 +391,6 @@
|
|||
--text-button-group-color: var(--ui-white);
|
||||
--text-button-dangerlight-color: var(--ui-error-7);
|
||||
--text-stepper-active-color: var(--ui-white);
|
||||
--text-boxselector-header: var(--ui-white);
|
||||
|
||||
--border-color: var(--grey-3);
|
||||
--border-widget-color: var(--grey-1);
|
||||
--border-sidebar-color: var(--ui-gray-8);
|
||||
|
@ -410,7 +400,6 @@
|
|||
--border-datatable-top-color: var(--grey-3);
|
||||
--border-input-group-addon-color: var(--grey-38);
|
||||
--border-btn-default-color: var(--grey-38);
|
||||
--border-boxselector-color: var(--grey-1);
|
||||
--border-md-checkbox-color: var(--grey-41);
|
||||
--border-modal-header-color: var(--grey-1);
|
||||
--border-navtabs-color: var(--grey-38);
|
||||
|
@ -519,8 +508,6 @@
|
|||
--bg-code-color: var(--ui-black);
|
||||
--bg-btn-default-hover-color: var(--grey-4);
|
||||
--bg-btn-focus: var(--black-color);
|
||||
--bg-boxselector-color: var(--black-color);
|
||||
--bg-boxselector-disabled-color: var(--black-color);
|
||||
--bg-small-select-color: var(--black-color);
|
||||
--bg-stepper-item-active: var(--black-color);
|
||||
--bg-stepper-item-counter: var(--grey-3);
|
||||
|
@ -544,7 +531,6 @@
|
|||
--text-code-color: var(--red-7);
|
||||
--text-form-control-color: var(--white-color);
|
||||
--text-blocklist-hover-color: var(--blue-11);
|
||||
--text-boxselector-wrapper-color: var(--white-color);
|
||||
--text-dashboard-item-color: var(--blue-12);
|
||||
--text-muted-color: var(--white-color);
|
||||
--text-tooltip-color: var(--white-color);
|
||||
|
@ -574,7 +560,6 @@
|
|||
--text-bootbox: var(--white-color);
|
||||
--text-pagination-span-hover-color: var(--ui-white);
|
||||
--text-stepper-active-color: var(--ui-white);
|
||||
--text-boxselector-header: var(--ui-white);
|
||||
|
||||
--border-color: var(--grey-55);
|
||||
--border-widget-color: var(--white-color);
|
||||
|
|
|
@ -1,5 +0,0 @@
|
|||
<svg width="17" height="16" viewBox="0 0 17 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect x="1.21426" y="0.5" width="15" height="15" rx="7.5" fill="#0086C9"/>
|
||||
<path d="M12.0474 5.5L7.4641 10.0833L5.38077 8" stroke="white" stroke-width="1.66667" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<rect x="1.21426" y="0.5" width="15" height="15" rx="7.5" stroke="#0086C9"/>
|
||||
</svg>
|
Before Width: | Height: | Size: 390 B |
|
@ -1,39 +1,14 @@
|
|||
<div>
|
||||
<div class="col-sm-12 form-section-title"> Macvlan configuration </div>
|
||||
<!-- selector -->
|
||||
|
||||
<div class="form-group">
|
||||
<span class="col-sm-12 text-muted small vertical-center">
|
||||
<pr-icon icon="'alert-circle'" mode="'primary'"></pr-icon>
|
||||
To create a MACVLAN network you need to create a configuration, then create the network from this configuration.
|
||||
</span>
|
||||
</div>
|
||||
<div class="col-sm-12">
|
||||
<div class="form-group">
|
||||
<div class="boxselector_wrapper">
|
||||
<div>
|
||||
<input type="radio" id="network_config" ng-model="$ctrl.data.Scope" value="local" />
|
||||
<label for="network_config">
|
||||
<div class="boxselector_header">
|
||||
<pr-icon icon="'sliders'"></pr-icon>
|
||||
Configuration
|
||||
</div>
|
||||
<p>I want to configure a network before deploying it</p>
|
||||
</label>
|
||||
</div>
|
||||
<div>
|
||||
<input type="radio" id="network_deploy" ng-model="$ctrl.data.Scope" value="swarm" ng-disabled="$ctrl.availableNetworks.length === 0" />
|
||||
<label for="network_deploy" ng-class="$ctrl.availableNetworks.length === 0 ? 'boxselector_disabled' : ''">
|
||||
<div class="boxselector_header">
|
||||
<pr-icon icon="'share-2'"></pr-icon>
|
||||
Creation
|
||||
</div>
|
||||
<p>I want to create a network from a configuration</p>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- !selector -->
|
||||
|
||||
<box-selector slim="true" options="$ctrl.options" value="$ctrl.data.Scope" on-change="($ctrl.onChangeScope)"></box-selector>
|
||||
|
||||
<ng-form name="macvlanConfigurationForm">
|
||||
<!-- configuration-inputs -->
|
||||
|
|
|
@ -1,13 +1,17 @@
|
|||
import { getOptions } from './options';
|
||||
|
||||
angular.module('portainer.docker').controller('NetworkMacvlanFormController', [
|
||||
'$q',
|
||||
'NodeService',
|
||||
'NetworkService',
|
||||
'Notifications',
|
||||
'StateManager',
|
||||
'$scope',
|
||||
'Authentication',
|
||||
function ($q, NodeService, NetworkService, Notifications, StateManager, Authentication) {
|
||||
function ($q, NodeService, NetworkService, Notifications, $scope, Authentication) {
|
||||
var ctrl = this;
|
||||
|
||||
this.options = [];
|
||||
|
||||
ctrl.requiredNodeSelection = function () {
|
||||
if (ctrl.data.Scope !== 'local' || ctrl.data.DatatableState === undefined) {
|
||||
return false;
|
||||
|
@ -22,6 +26,13 @@ angular.module('portainer.docker').controller('NetworkMacvlanFormController', [
|
|||
return !ctrl.data.SelectedNetworkConfig;
|
||||
};
|
||||
|
||||
this.onChangeScope = onChangeScope.bind(this);
|
||||
function onChangeScope(value) {
|
||||
return $scope.$evalAsync(() => {
|
||||
this.data.Scope = value;
|
||||
});
|
||||
}
|
||||
|
||||
this.$onInit = $onInit;
|
||||
function $onInit() {
|
||||
var isAdmin = Authentication.isAdmin();
|
||||
|
@ -40,6 +51,8 @@ angular.module('portainer.docker').controller('NetworkMacvlanFormController', [
|
|||
ctrl.availableNetworks = data.networks.filter(function (item) {
|
||||
return item.ConfigOnly === true;
|
||||
});
|
||||
|
||||
ctrl.options = getOptions(ctrl.availableNetworks.length > 0);
|
||||
})
|
||||
.catch(function error(err) {
|
||||
Notifications.error('Failure', err, 'Unable to retrieve informations for macvlan');
|
||||
|
|
|
@ -0,0 +1,27 @@
|
|||
import { Share2, Sliders } from 'lucide-react';
|
||||
|
||||
import { BoxSelectorOption } from '@@/BoxSelector';
|
||||
|
||||
export function getOptions(
|
||||
hasNetworks: boolean
|
||||
): ReadonlyArray<BoxSelectorOption<string>> {
|
||||
return [
|
||||
{
|
||||
id: 'network_config',
|
||||
icon: Sliders,
|
||||
iconType: 'badge',
|
||||
label: 'Configuration',
|
||||
description: 'I want to configure a network before deploying it',
|
||||
value: 'local',
|
||||
},
|
||||
{
|
||||
id: 'network_deploy',
|
||||
icon: Share2,
|
||||
iconType: 'badge',
|
||||
label: 'Creation',
|
||||
description: 'I want to create a network from a configuration',
|
||||
value: 'swarm',
|
||||
disabled: () => !hasNetworks,
|
||||
},
|
||||
] as const;
|
||||
}
|
|
@ -1,8 +1,11 @@
|
|||
import { options } from './options';
|
||||
|
||||
angular.module('portainer.docker').controller('BuildImageController', BuildImageController);
|
||||
|
||||
/* @ngInject */
|
||||
function BuildImageController($scope, $async, $window, ModalService, BuildService, Notifications, HttpRequestHelper, endpoint) {
|
||||
$scope.endpoint = endpoint;
|
||||
$scope.options = options;
|
||||
|
||||
$scope.state = {
|
||||
BuildType: 'editor',
|
||||
|
@ -31,6 +34,12 @@ function BuildImageController($scope, $async, $window, ModalService, BuildServic
|
|||
$scope.state.isEditorDirty = false;
|
||||
});
|
||||
|
||||
$scope.onChangeBuildType = function (type) {
|
||||
$scope.$evalAsync(() => {
|
||||
$scope.state.BuildType = type;
|
||||
});
|
||||
};
|
||||
|
||||
$scope.checkName = function (index) {
|
||||
var item = $scope.formValues.ImageNames[index];
|
||||
item.Valid = true;
|
||||
|
|
|
@ -69,46 +69,9 @@
|
|||
</div>
|
||||
</div>
|
||||
<!-- !name-input-list -->
|
||||
<!-- build-method -->
|
||||
<div class="col-sm-12 form-section-title"> Build method </div>
|
||||
<div class="form-group"></div>
|
||||
<div class="form-group">
|
||||
<div class="col-sm-12">
|
||||
<div class="boxselector_wrapper">
|
||||
<div>
|
||||
<input type="radio" id="method_editor" ng-model="state.BuildType" value="editor" ng-click="toggleEditor()" />
|
||||
<label for="method_editor">
|
||||
<div class="boxselector_header vertical-center">
|
||||
<pr-icon icon="'edit'"></pr-icon>
|
||||
Web editor
|
||||
</div>
|
||||
<p>Use our Web editor</p>
|
||||
</label>
|
||||
</div>
|
||||
<div>
|
||||
<input type="radio" id="method_upload" ng-model="state.BuildType" value="upload" ng-click="saveEditorContent()" />
|
||||
<label for="method_upload">
|
||||
<div class="boxselector_header vertical-center">
|
||||
<pr-icon icon="'upload'"></pr-icon>
|
||||
Upload
|
||||
</div>
|
||||
<p>Upload a tarball or a Dockerfile from your computer</p>
|
||||
</label>
|
||||
</div>
|
||||
<div>
|
||||
<input type="radio" id="method_url" ng-model="state.BuildType" value="url" ng-click="saveEditorContent()" />
|
||||
<label for="method_url">
|
||||
<div class="boxselector_header vertical-center">
|
||||
<pr-icon icon="'globe'"></pr-icon>
|
||||
URL
|
||||
</div>
|
||||
<p>Specify a URL to a file</p>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- !build-method -->
|
||||
<box-selector options="options" slim="true" value="state.BuildType" on-change="(onChangeBuildType)"></box-selector>
|
||||
|
||||
<!-- web-editor -->
|
||||
<div ng-show="state.BuildType === 'editor'">
|
||||
<div class="col-sm-12 form-section-title"> Web editor </div>
|
||||
|
|
|
@ -0,0 +1,7 @@
|
|||
import {
|
||||
editor,
|
||||
upload,
|
||||
url,
|
||||
} from '@@/BoxSelector/common-options/build-methods';
|
||||
|
||||
export const options = [editor, upload, url] as const;
|
|
@ -0,0 +1,22 @@
|
|||
import { Calendar, Edit } from 'lucide-react';
|
||||
|
||||
import { BoxSelectorOption } from '@@/BoxSelector';
|
||||
|
||||
export const cronMethodOptions: ReadonlyArray<BoxSelectorOption<string>> = [
|
||||
{
|
||||
id: 'config_basic',
|
||||
value: 'basic',
|
||||
icon: Calendar,
|
||||
iconType: 'badge',
|
||||
label: 'Basic configuration',
|
||||
description: 'Select date from calendar',
|
||||
},
|
||||
{
|
||||
id: 'config_advanced',
|
||||
value: 'advanced',
|
||||
icon: Edit,
|
||||
iconType: 'badge',
|
||||
label: 'Advanced configuration',
|
||||
description: 'Write your own cron rule',
|
||||
},
|
||||
] as const;
|
|
@ -34,33 +34,9 @@
|
|||
<!-- cron-input -->
|
||||
<!-- edge-job-method-select -->
|
||||
<div class="col-sm-12 form-section-title"> Edge job configuration </div>
|
||||
<div class="form-group"></div>
|
||||
<div class="form-group">
|
||||
<div class="col-sm-12">
|
||||
<div class="boxselector_wrapper !mt-0">
|
||||
<div>
|
||||
<input type="radio" id="config_basic" ng-model="$ctrl.formValues.cronMethod" value="basic" />
|
||||
<label for="config_basic">
|
||||
<div class="boxselector_header vertical-center">
|
||||
<pr-icon icon="'calendar'"></pr-icon>
|
||||
Basic configuration
|
||||
</div>
|
||||
<p>Select date from calendar</p>
|
||||
</label>
|
||||
</div>
|
||||
<div>
|
||||
<input type="radio" id="config_advanced" ng-model="$ctrl.formValues.cronMethod" value="advanced" />
|
||||
<label for="config_advanced">
|
||||
<div class="boxselector_header vertical-center">
|
||||
<pr-icon icon="'edit'"></pr-icon>
|
||||
Advanced configuration
|
||||
</div>
|
||||
<p>Write your own cron rule</p>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<box-selector slim="true" radio-name="'configuration'" value="$ctrl.formValues.cronMethod" options="$ctrl.cronMethods" on-change="($ctrl.onCronMethodChange)"></box-selector>
|
||||
|
||||
<!-- !edge-job-method-select -->
|
||||
<!-- basic-edge-job -->
|
||||
<div ng-if="$ctrl.formValues.cronMethod === 'basic'">
|
||||
|
@ -154,34 +130,10 @@
|
|||
<!-- execution-method -->
|
||||
<div ng-if="!$ctrl.model.Id">
|
||||
<div class="col-sm-12 form-section-title"> Job content </div>
|
||||
<div class="form-group">
|
||||
<div class="col-sm-12">
|
||||
<div class="boxselector_wrapper">
|
||||
<div>
|
||||
<input type="radio" id="method_editor" ng-model="$ctrl.formValues.method" value="editor" />
|
||||
<label for="method_editor">
|
||||
<div class="boxselector_header vertical-center">
|
||||
<pr-icon icon="'edit'"></pr-icon>
|
||||
Web editor
|
||||
</div>
|
||||
<p>Use our Web editor</p>
|
||||
</label>
|
||||
</div>
|
||||
<div>
|
||||
<input type="radio" id="method_upload" ng-model="$ctrl.formValues.method" value="upload" />
|
||||
<label for="method_upload">
|
||||
<div class="boxselector_header vertical-center">
|
||||
<pr-icon icon="'upload'"></pr-icon>
|
||||
Upload
|
||||
</div>
|
||||
<p>Upload from your computer</p>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<box-selector value="$ctrl.formValues.method" options="$ctrl.buildMethods" radio-name="buildMethod" on-change="($ctrl.onBuildMethodChange)" slim="true"></box-selector>
|
||||
</div>
|
||||
<!-- !execution-method -->
|
||||
|
||||
<!-- web-editor -->
|
||||
<div ng-show="$ctrl.formValues.method === 'editor'">
|
||||
<div class="col-sm-12 form-section-title"> Web editor </div>
|
||||
|
|
|
@ -1,9 +1,20 @@
|
|||
import _ from 'lodash-es';
|
||||
import moment from 'moment';
|
||||
import { editor, upload } from '@@/BoxSelector/common-options/build-methods';
|
||||
|
||||
import { cronMethodOptions } from './cron-method-options';
|
||||
|
||||
export class EdgeJobFormController {
|
||||
/* @ngInject */
|
||||
constructor($async, $scope, EdgeGroupService, Notifications) {
|
||||
this.$scope = $scope;
|
||||
this.$async = $async;
|
||||
this.EdgeGroupService = EdgeGroupService;
|
||||
this.Notifications = Notifications;
|
||||
|
||||
this.cronMethods = cronMethodOptions;
|
||||
this.buildMethods = [editor, upload];
|
||||
|
||||
this.state = {
|
||||
formValidationError: '',
|
||||
};
|
||||
|
@ -34,17 +45,31 @@ export class EdgeJobFormController {
|
|||
this.cronRegex =
|
||||
/(@(annually|yearly|monthly|weekly|daily|hourly|reboot))|(@every (\d+(ns|us|µs|ms|s|m|h))+)|((((\d+,)+\d+|(\d+(\/|-)\d+)|\d+|\*) ){4,6}((\d+,)+\d+|(\d+(\/|-)\d+)|\d+|\*))/;
|
||||
|
||||
this.$async = $async;
|
||||
this.$scope = $scope;
|
||||
|
||||
this.action = this.action.bind(this);
|
||||
this.editorUpdate = this.editorUpdate.bind(this);
|
||||
this.associateEndpoint = this.associateEndpoint.bind(this);
|
||||
this.dissociateEndpoint = this.dissociateEndpoint.bind(this);
|
||||
this.onChangeGroups = this.onChangeGroups.bind(this);
|
||||
this.onChange = this.onChange.bind(this);
|
||||
this.onCronMethodChange = this.onCronMethodChange.bind(this);
|
||||
this.onBuildMethodChange = this.onBuildMethodChange.bind(this);
|
||||
}
|
||||
|
||||
this.EdgeGroupService = EdgeGroupService;
|
||||
this.Notifications = Notifications;
|
||||
onChange(values) {
|
||||
this.$scope.$evalAsync(() => {
|
||||
this.formValues = {
|
||||
...this.formValues,
|
||||
...values,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
onBuildMethodChange(value) {
|
||||
this.onChange({ method: value });
|
||||
}
|
||||
|
||||
onCronMethodChange(value) {
|
||||
this.onChange({ cronMethod: value });
|
||||
}
|
||||
|
||||
onChangeModel(model) {
|
||||
|
|
|
@ -0,0 +1,22 @@
|
|||
import { List, Tag } from 'lucide-react';
|
||||
|
||||
import { BoxSelectorOption } from '@@/BoxSelector';
|
||||
|
||||
export const groupTypeOptions: ReadonlyArray<BoxSelectorOption<boolean>> = [
|
||||
{
|
||||
id: 'static-group',
|
||||
value: false,
|
||||
label: 'Static',
|
||||
description: 'Manually select Edge environments',
|
||||
icon: List,
|
||||
iconType: 'badge',
|
||||
},
|
||||
{
|
||||
id: 'dynamic-group',
|
||||
value: true,
|
||||
label: 'Dynamic',
|
||||
description: 'Automatically associate environments via tags',
|
||||
icon: Tag,
|
||||
iconType: 'badge',
|
||||
},
|
||||
] as const;
|
|
@ -24,32 +24,8 @@
|
|||
</div>
|
||||
|
||||
<div class="col-sm-12 form-section-title"> Group type </div>
|
||||
<div class="form-group">
|
||||
<div class="col-sm-12">
|
||||
<div class="boxselector_wrapper">
|
||||
<div class="boxselector">
|
||||
<input type="radio" id="static-group" ng-model="$ctrl.model.Dynamic" ng-value="false" ng-checked="!$ctrl.model.Dynamic" />
|
||||
<label for="static-group">
|
||||
<div class="boxselector_header vertical-center">
|
||||
<pr-icon icon="'list'"></pr-icon>
|
||||
Static
|
||||
</div>
|
||||
<p>Manually select Edge environments</p>
|
||||
</label>
|
||||
</div>
|
||||
<div class="boxselector">
|
||||
<input type="radio" id="dynamic-group" ng-model="$ctrl.model.Dynamic" ng-value="true" ng-checked="$ctrl.model.Dynamic" />
|
||||
<label for="dynamic-group">
|
||||
<div class="boxselector_header vertical-center">
|
||||
<pr-icon icon="'tag'"></pr-icon>
|
||||
Dynamic
|
||||
</div>
|
||||
<p>Automatically associate environments via tags</p>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<box-selector slim="true" value="$ctrl.model.Dynamic" on-change="($ctrl.onChangeDynamic)" options="$ctrl.groupTypeOptions"></box-selector>
|
||||
|
||||
<!-- StaticGroup -->
|
||||
<div ng-if="!$ctrl.model.Dynamic">
|
||||
|
@ -78,32 +54,8 @@
|
|||
<!-- DynamicGroup -->
|
||||
<div ng-if="$ctrl.model.Dynamic">
|
||||
<div class="col-sm-12 form-section-title"> Tags </div>
|
||||
<div class="form-group">
|
||||
<div class="col-sm-12">
|
||||
<div class="boxselector_wrapper">
|
||||
<div class="boxselector">
|
||||
<input type="radio" id="or-selector" ng-model="$ctrl.model.PartialMatch" ng-value="true" ng-checked="$ctrl.model.PartialMatch" />
|
||||
<label for="or-selector">
|
||||
<div class="boxselector_header vertical-center">
|
||||
<pr-icon icon="'tag'"></pr-icon>
|
||||
Partial match
|
||||
</div>
|
||||
<p>Associate any environment matching at least one of the selected tags</p>
|
||||
</label>
|
||||
</div>
|
||||
<div class="boxselector">
|
||||
<input type="radio" id="and-selector" ng-model="$ctrl.model.PartialMatch" ng-value="false" ng-checked="!$ctrl.model.PartialMatch" />
|
||||
<label for="and-selector">
|
||||
<div class="boxselector_header vertical-center">
|
||||
<pr-icon icon="'tag'"></pr-icon>
|
||||
Full match
|
||||
</div>
|
||||
<p>Associate any environment matching all of the selected tags</p>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<box-selector slim="true" value="$ctrl.model.PartialMatch" on-change="($ctrl.onChangePartialMatch)" options="$ctrl.tagOptions"></box-selector>
|
||||
|
||||
<tag-selector ng-if="$ctrl.model.TagIds" value="$ctrl.model.TagIds" on-change="($ctrl.onChangeTags)"> </tag-selector>
|
||||
|
||||
|
|
|
@ -4,6 +4,8 @@ import { EdgeTypes } from '@/react/portainer/environments/types';
|
|||
import { getEnvironments } from '@/react/portainer/environments/environment.service';
|
||||
import { getTags } from '@/portainer/tags/tags.service';
|
||||
import { notifyError } from '@/portainer/services/notifications';
|
||||
import { groupTypeOptions } from './group-type-options';
|
||||
import { tagOptions } from './tag-options';
|
||||
|
||||
export class EdgeGroupFormController {
|
||||
/* @ngInject */
|
||||
|
@ -11,6 +13,9 @@ export class EdgeGroupFormController {
|
|||
this.$async = $async;
|
||||
this.$scope = $scope;
|
||||
|
||||
this.groupTypeOptions = groupTypeOptions;
|
||||
this.tagOptions = tagOptions;
|
||||
|
||||
this.endpoints = {
|
||||
state: {
|
||||
limit: '10',
|
||||
|
@ -28,6 +33,9 @@ export class EdgeGroupFormController {
|
|||
this.getDynamicEndpointsAsync = this.getDynamicEndpointsAsync.bind(this);
|
||||
this.getDynamicEndpoints = this.getDynamicEndpoints.bind(this);
|
||||
this.onChangeTags = this.onChangeTags.bind(this);
|
||||
this.onChangeDynamic = this.onChangeDynamic.bind(this);
|
||||
this.onChangeModel = this.onChangeModel.bind(this);
|
||||
this.onChangePartialMatch = this.onChangePartialMatch.bind(this);
|
||||
|
||||
$scope.$watch(
|
||||
() => this.model,
|
||||
|
@ -40,12 +48,27 @@ export class EdgeGroupFormController {
|
|||
);
|
||||
}
|
||||
|
||||
onChangeTags(value) {
|
||||
onChangeModel(model) {
|
||||
return this.$scope.$evalAsync(() => {
|
||||
this.model.TagIds = value;
|
||||
this.model = {
|
||||
...this.model,
|
||||
...model,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
onChangePartialMatch(value) {
|
||||
return this.onChangeModel({ PartialMatch: value });
|
||||
}
|
||||
|
||||
onChangeDynamic(value) {
|
||||
this.onChangeModel({ Dynamic: value });
|
||||
}
|
||||
|
||||
onChangeTags(value) {
|
||||
this.onChangeModel({ TagIds: value });
|
||||
}
|
||||
|
||||
associateEndpoint(endpoint) {
|
||||
if (!_.includes(this.model.Endpoints, endpoint.Id)) {
|
||||
this.model.Endpoints = [...this.model.Endpoints, endpoint.Id];
|
||||
|
|
|
@ -0,0 +1,23 @@
|
|||
import { Tag } from 'lucide-react';
|
||||
|
||||
import { BoxSelectorOption } from '@@/BoxSelector';
|
||||
|
||||
export const tagOptions: ReadonlyArray<BoxSelectorOption<boolean>> = [
|
||||
{
|
||||
id: 'or-selector',
|
||||
value: true,
|
||||
label: 'Partial Match',
|
||||
description:
|
||||
'Associate any environment matching at least one of the selected tags',
|
||||
icon: Tag,
|
||||
iconType: 'badge',
|
||||
},
|
||||
{
|
||||
id: 'and-selector',
|
||||
value: false,
|
||||
label: 'Full Match',
|
||||
description: 'Associate any environment matching all of the selected tags',
|
||||
icon: Tag,
|
||||
iconType: 'badge',
|
||||
},
|
||||
];
|
|
@ -1,11 +1,11 @@
|
|||
import { editor, git, template, upload } from '@@/BoxSelector/common-options/build-methods';
|
||||
import { editor, git, edgeStackTemplate, upload } from '@@/BoxSelector/common-options/build-methods';
|
||||
|
||||
class DockerComposeFormController {
|
||||
/* @ngInject */
|
||||
constructor($async, EdgeTemplateService, Notifications) {
|
||||
Object.assign(this, { $async, EdgeTemplateService, Notifications });
|
||||
|
||||
this.methodOptions = [editor, upload, git, template];
|
||||
this.methodOptions = [editor, upload, git, edgeStackTemplate];
|
||||
|
||||
this.selectedTemplate = null;
|
||||
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
<div class="col-sm-12 form-section-title"> Build method </div>
|
||||
<box-selector radio-name="'method'" value="$ctrl.state.Method" options="$ctrl.methodOptions" on-change="($ctrl.onChangeMethod)"></box-selector>
|
||||
<box-selector slim="true" radio-name="'method'" value="$ctrl.state.Method" options="$ctrl.methodOptions" on-change="($ctrl.onChangeMethod)"></box-selector>
|
||||
|
||||
<web-editor-form
|
||||
ng-if="$ctrl.state.Method === 'editor'"
|
||||
|
|
|
@ -10,7 +10,7 @@
|
|||
</div>
|
||||
|
||||
<div class="col-sm-12 form-section-title"> Build method </div>
|
||||
<box-selector radio-name="'method'" value="$ctrl.state.Method" options="$ctrl.methodOptions" on-change="($ctrl.onChangeMethod)"></box-selector>
|
||||
<box-selector slim="true" radio-name="'method'" value="$ctrl.state.Method" options="$ctrl.methodOptions" on-change="($ctrl.onChangeMethod)"></box-selector>
|
||||
|
||||
<web-editor-form
|
||||
ng-if="$ctrl.state.Method === 'editor'"
|
||||
|
|
|
@ -42,8 +42,8 @@
|
|||
<div
|
||||
id="page-wrapper"
|
||||
ng-class="{
|
||||
open: isSidebarOpen() && ['portainer.auth', 'portainer.init.admin', 'portainer.init.endpoint'].indexOf($state.current.name) === -1,
|
||||
nopadding: ['portainer.auth', 'portainer.init.admin', 'portainer.init.endpoint', 'portainer.logout'].indexOf($state.current.name) > -1 || applicationState.loading
|
||||
open: isSidebarOpen() && ['portainer.auth', 'portainer.init.admin'].indexOf($state.current.name) === -1,
|
||||
nopadding: ['portainer.auth', 'portainer.init.admin', 'portainer.logout'].indexOf($state.current.name) > -1 || applicationState.loading
|
||||
}"
|
||||
ng-cloak
|
||||
>
|
||||
|
|
|
@ -9,7 +9,7 @@
|
|||
|
||||
<!-- build-method -->
|
||||
<div class="col-sm-12 form-section-title"> Build method </div>
|
||||
<box-selector radio-name="'method'" value="$ctrl.state.method" options="$ctrl.methodOptions" on-change="($ctrl.onChangeMethod)"></box-selector>
|
||||
<box-selector slim="true" radio-name="'method'" value="$ctrl.state.method" options="$ctrl.methodOptions" on-change="($ctrl.onChangeMethod)"></box-selector>
|
||||
|
||||
<div class="mt-4">
|
||||
<web-editor-form
|
||||
|
|
|
@ -6,6 +6,8 @@ import { NamespacesSelector } from '@/react/kubernetes/cluster/RegistryAccessVie
|
|||
import { StorageAccessModeSelector } from '@/react/kubernetes/cluster/ConfigureView/StorageAccessModeSelector';
|
||||
import { NamespaceAccessUsersSelector } from '@/react/kubernetes/namespaces/AccessView/NamespaceAccessUsersSelector';
|
||||
import { CreateNamespaceRegistriesSelector } from '@/react/kubernetes/namespaces/CreateView/CreateNamespaceRegistriesSelector';
|
||||
import { KubeApplicationAccessPolicySelector } from '@/react/kubernetes/applications/CreateView/KubeApplicationAccessPolicySelector';
|
||||
import { KubeApplicationDeploymentTypeSelector } from '@/react/kubernetes/applications/CreateView/KubeApplicationDeploymentTypeSelector';
|
||||
|
||||
export const componentsModule = angular
|
||||
.module('portainer.kubernetes.react.components', [])
|
||||
|
@ -63,4 +65,21 @@ export const componentsModule = angular
|
|||
'options',
|
||||
'value',
|
||||
])
|
||||
)
|
||||
.component(
|
||||
'kubeApplicationAccessPolicySelector',
|
||||
r2a(KubeApplicationAccessPolicySelector, [
|
||||
'value',
|
||||
'onChange',
|
||||
'isEdit',
|
||||
'persistedFoldersUseExistingVolumes',
|
||||
])
|
||||
)
|
||||
.component(
|
||||
'kubeApplicationDeploymentTypeSelector',
|
||||
r2a(KubeApplicationDeploymentTypeSelector, [
|
||||
'value',
|
||||
'onChange',
|
||||
'supportGlobalDeployment',
|
||||
])
|
||||
).name;
|
||||
|
|
|
@ -734,90 +734,12 @@
|
|||
<div class="col-sm-12 small text-muted"> Specify how the data will be used across instances. </div>
|
||||
</div>
|
||||
|
||||
<!-- access policy options -->
|
||||
<div class="form-group">
|
||||
<div class="col-sm-12">
|
||||
<div class="boxselector_wrapper">
|
||||
<div
|
||||
ng-if="
|
||||
(!ctrl.state.isEdit && !ctrl.state.persistedFoldersUseExistingVolumes) ||
|
||||
(ctrl.state.isEdit && ctrl.formValues.DataAccessPolicy === ctrl.ApplicationDataAccessPolicies.ISOLATED)
|
||||
"
|
||||
>
|
||||
<input
|
||||
type="radio"
|
||||
id="data_access_isolated"
|
||||
ng-value="ctrl.ApplicationDataAccessPolicies.ISOLATED"
|
||||
ng-model="ctrl.formValues.DataAccessPolicy"
|
||||
ng-change="ctrl.resetDeploymentType()"
|
||||
/>
|
||||
<label for="data_access_isolated">
|
||||
<div class="boxselector_header">
|
||||
<pr-icon icon="'boxes'"></pr-icon>
|
||||
Isolated
|
||||
</div>
|
||||
<p>Application will be deployed as a StatefulSet with each instantiating their own data</p>
|
||||
</label>
|
||||
</div>
|
||||
<div
|
||||
style="color: #767676"
|
||||
ng-if="
|
||||
(ctrl.state.isEdit && ctrl.formValues.DataAccessPolicy === ctrl.ApplicationDataAccessPolicies.SHARED) || ctrl.state.persistedFoldersUseExistingVolumes
|
||||
"
|
||||
>
|
||||
<input type="radio" id="data_access_isolated" disabled />
|
||||
<label
|
||||
for="data_access_isolated"
|
||||
tooltip-append-to-body="true"
|
||||
tooltip-placement="bottom"
|
||||
tooltip-class="portainer-tooltip"
|
||||
uib-tooltip="Changing the data access policy is not allowed"
|
||||
style="cursor: pointer; border-color: #767676"
|
||||
>
|
||||
<div class="boxselector_header">
|
||||
<pr-icon icon="'boxes'"></pr-icon>
|
||||
Isolated
|
||||
</div>
|
||||
<p>Application will be deployed as a StatefulSet with each instantiating their own data</p>
|
||||
</label>
|
||||
</div>
|
||||
<div ng-if="!ctrl.state.isEdit || (ctrl.state.isEdit && ctrl.formValues.DataAccessPolicy === ctrl.ApplicationDataAccessPolicies.SHARED)">
|
||||
<input
|
||||
type="radio"
|
||||
id="data_access_shared"
|
||||
ng-value="ctrl.ApplicationDataAccessPolicies.SHARED"
|
||||
ng-model="ctrl.formValues.DataAccessPolicy"
|
||||
ng-change="ctrl.resetDeploymentType()"
|
||||
/>
|
||||
<label for="data_access_shared">
|
||||
<div class="boxselector_header">
|
||||
<pr-icon icon="'box'"></pr-icon>
|
||||
Shared
|
||||
</div>
|
||||
<p>Application will be deployed as a Deployment with a shared storage access</p>
|
||||
</label>
|
||||
</div>
|
||||
<div style="color: #767676" ng-if="ctrl.state.isEdit && ctrl.formValues.DataAccessPolicy === ctrl.ApplicationDataAccessPolicies.ISOLATED">
|
||||
<input type="radio" id="data_access_shared" disabled />
|
||||
<label
|
||||
for="data_access_shared"
|
||||
tooltip-append-to-body="true"
|
||||
tooltip-placement="bottom"
|
||||
tooltip-class="portainer-tooltip"
|
||||
uib-tooltip="Changing the data access policy is not allowed"
|
||||
style="cursor: pointer; border-color: #767676"
|
||||
>
|
||||
<div class="boxselector_header">
|
||||
<pr-icon icon="'sliders'"></pr-icon>
|
||||
Shared
|
||||
</div>
|
||||
<p>Application will be deployed as a Deployment with a shared storage access</p>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- !access policy options -->
|
||||
<kube-application-access-policy-selector
|
||||
value="ctrl.formValues.DataAccessPolicy"
|
||||
on-change="(ctrl.onDataAccessPolicyChange)"
|
||||
is-edit="ctrl.state.isEdit"
|
||||
persisted-folders-use-existing-volumes="ctrl.state.persistedFoldersUseExistingVolumes"
|
||||
></kube-application-access-policy-selector>
|
||||
</div>
|
||||
<!-- #endregion -->
|
||||
|
||||
|
@ -925,62 +847,12 @@
|
|||
</div>
|
||||
|
||||
<!-- deployment options -->
|
||||
<div class="form-group">
|
||||
<div class="col-sm-12">
|
||||
<div class="boxselector_wrapper">
|
||||
<div>
|
||||
<input
|
||||
type="radio"
|
||||
id="deployment_replicated"
|
||||
ng-value="ctrl.ApplicationDeploymentTypes.REPLICATED"
|
||||
ng-model="ctrl.formValues.DeploymentType"
|
||||
data-cy="k8sAppCreate-replicatedDeploymentButton"
|
||||
/>
|
||||
<label for="deployment_replicated">
|
||||
<div class="boxselector_header">
|
||||
<pr-icon icon="'sliders'"></pr-icon>
|
||||
Replicated
|
||||
</div>
|
||||
<p>Run one or multiple instances of this container</p>
|
||||
</label>
|
||||
</div>
|
||||
<div ng-if="!ctrl.supportGlobalDeployment()">
|
||||
<input type="radio" id="deployment_global" disabled />
|
||||
<label
|
||||
for="deployment_global"
|
||||
tooltip-append-to-body="true"
|
||||
tooltip-placement="bottom"
|
||||
tooltip-class="portainer-tooltip"
|
||||
uib-tooltip="The storage or access policy used for persisted folders cannot be used with this option"
|
||||
>
|
||||
<div class="boxselector_header">
|
||||
<pr-icon icon="'boxes'"></pr-icon>
|
||||
Global
|
||||
</div>
|
||||
<p>Application will be deployed as a DaemonSet with an instance on each node of the cluster</p>
|
||||
</label>
|
||||
</div>
|
||||
<div ng-if="ctrl.supportGlobalDeployment()">
|
||||
<input
|
||||
type="radio"
|
||||
id="deployment_global"
|
||||
ng-value="ctrl.ApplicationDeploymentTypes.GLOBAL"
|
||||
ng-model="ctrl.formValues.DeploymentType"
|
||||
ng-click="ctrl.unselectAutoScaler()"
|
||||
data-cy="k8sAppCreate-globalDeployButton"
|
||||
/>
|
||||
<label for="deployment_global">
|
||||
<div class="boxselector_header">
|
||||
<pr-icon icon="'boxes'"></pr-icon>
|
||||
Global
|
||||
</div>
|
||||
<p>Application will be deployed as a DaemonSet with an instance on each node of the cluster</p>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- !deployment options -->
|
||||
<kube-application-deployment-type-selector
|
||||
value="ctrl.formValues.DeploymentType"
|
||||
on-change="(ctrl.onChangeDeploymentType)"
|
||||
support-global-deployment="ctrl.supportGlobalDeployment()"
|
||||
radio-name="'deploymentType'"
|
||||
></kube-application-deployment-type-selector>
|
||||
|
||||
<!-- replica count -->
|
||||
<div class="form-group" ng-if="ctrl.formValues.DeploymentType === ctrl.ApplicationDeploymentTypes.REPLICATED">
|
||||
|
@ -1260,46 +1132,14 @@
|
|||
<div class="col-sm-12 small text-muted"> Specify the policy associated to the placement rules. </div>
|
||||
</div>
|
||||
|
||||
<!-- placement policy options -->
|
||||
<div class="form-group" ng-if="ctrl.formValues.Placements.length">
|
||||
<div class="col-sm-12">
|
||||
<div class="boxselector_wrapper">
|
||||
<div>
|
||||
<input
|
||||
type="radio"
|
||||
id="placement_hard"
|
||||
ng-value="ctrl.ApplicationPlacementTypes.MANDATORY"
|
||||
ng-model="ctrl.formValues.PlacementType"
|
||||
data-cy="k8sAppCreate-mandatoryPlacementButton"
|
||||
/>
|
||||
<label for="placement_hard">
|
||||
<div class="boxselector_header">
|
||||
<pr-icon icon="'sliders'"></pr-icon>
|
||||
Mandatory
|
||||
</div>
|
||||
<p>Schedule this application <b>ONLY</b> on nodes that match <b>ALL</b> Rules</p>
|
||||
</label>
|
||||
</div>
|
||||
<div>
|
||||
<input
|
||||
type="radio"
|
||||
id="placement_soft"
|
||||
ng-value="ctrl.ApplicationPlacementTypes.PREFERRED"
|
||||
ng-model="ctrl.formValues.PlacementType"
|
||||
data-cy="k8sAppCreate-prefferedPlacementButton"
|
||||
/>
|
||||
<label for="placement_soft">
|
||||
<div class="boxselector_header">
|
||||
<pr-icon icon="'align-justify'"></pr-icon>
|
||||
Preferred
|
||||
</div>
|
||||
<p>Schedule this application on nodes that match the rules if possible</p>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- !placement policy options -->
|
||||
<box-selector
|
||||
ng-if="ctrl.formValues.Placements.length"
|
||||
options="ctrl.placementOptions"
|
||||
slim="true"
|
||||
value="ctrl.formValues.PlacementType"
|
||||
on-change="(ctrl.onChangePlacementType)"
|
||||
radio-name="'placementType'"
|
||||
></box-selector>
|
||||
</div>
|
||||
<!-- #endregion -->
|
||||
</div>
|
||||
|
|
|
@ -34,6 +34,7 @@ import KubernetesNamespaceHelper from 'Kubernetes/helpers/namespaceHelper';
|
|||
import { KubernetesNodeHelper } from 'Kubernetes/node/helper';
|
||||
import { updateIngress, getIngresses } from '@/react/kubernetes/ingresses/service';
|
||||
import { confirmUpdateAppIngress } from '@/portainer/services/modal.service/prompt';
|
||||
import { placementOptions } from './placementTypes';
|
||||
|
||||
class KubernetesCreateApplicationController {
|
||||
/* #region CONSTRUCTOR */
|
||||
|
@ -85,6 +86,8 @@ class KubernetesCreateApplicationController {
|
|||
this.ServiceTypes = KubernetesServiceTypes;
|
||||
this.KubernetesDeploymentTypes = KubernetesDeploymentTypes;
|
||||
|
||||
this.placementOptions = placementOptions;
|
||||
|
||||
this.state = {
|
||||
appType: this.KubernetesDeploymentTypes.APPLICATION_FORM,
|
||||
updateWebEditorInProgress: false,
|
||||
|
@ -148,9 +151,25 @@ class KubernetesCreateApplicationController {
|
|||
this.onServicePublishChange = this.onServicePublishChange.bind(this);
|
||||
this.checkIngressesToUpdate = this.checkIngressesToUpdate.bind(this);
|
||||
this.confirmUpdateApplicationAsync = this.confirmUpdateApplicationAsync.bind(this);
|
||||
this.onDataAccessPolicyChange = this.onDataAccessPolicyChange.bind(this);
|
||||
this.onChangeDeploymentType = this.onChangeDeploymentType.bind(this);
|
||||
this.supportGlobalDeployment = this.supportGlobalDeployment.bind(this);
|
||||
this.onChangePlacementType = this.onChangePlacementType.bind(this);
|
||||
}
|
||||
/* #endregion */
|
||||
|
||||
onChangePlacementType(value) {
|
||||
this.$scope.$evalAsync(() => {
|
||||
this.formValues.PlacementType = value;
|
||||
});
|
||||
}
|
||||
|
||||
onChangeDeploymentType(value) {
|
||||
this.$scope.$evalAsync(() => {
|
||||
this.formValues.DeploymentType = value;
|
||||
});
|
||||
}
|
||||
|
||||
onChangeFileContent(value) {
|
||||
if (this.stackFileContent.replace(/(\r\n|\n|\r)/gm, '') !== value.replace(/(\r\n|\n|\r)/gm, '')) {
|
||||
this.state.isEditorDirty = true;
|
||||
|
@ -158,6 +177,13 @@ class KubernetesCreateApplicationController {
|
|||
}
|
||||
}
|
||||
|
||||
onDataAccessPolicyChange(value) {
|
||||
this.$scope.$evalAsync(() => {
|
||||
this.formValues.DataAccessPolicy = value;
|
||||
this.resetDeploymentType();
|
||||
});
|
||||
}
|
||||
|
||||
async updateApplicationViaWebEditor() {
|
||||
return this.$async(async () => {
|
||||
try {
|
||||
|
@ -616,6 +642,7 @@ class KubernetesCreateApplicationController {
|
|||
if (hasFolders && (hasRWOOnly || isIsolated)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
|
|
|
@ -0,0 +1,30 @@
|
|||
import { AlignJustify, Sliders } from 'lucide-react';
|
||||
|
||||
import { KubernetesApplicationPlacementTypes } from '@/kubernetes/models/application/models';
|
||||
|
||||
import { BoxSelectorOption } from '@@/BoxSelector';
|
||||
|
||||
export const placementOptions: ReadonlyArray<BoxSelectorOption<number>> = [
|
||||
{
|
||||
id: 'placement_hard',
|
||||
value: KubernetesApplicationPlacementTypes.MANDATORY,
|
||||
icon: Sliders,
|
||||
iconType: 'badge',
|
||||
label: 'Mandatory',
|
||||
description: (
|
||||
<>
|
||||
Schedule this application <b>ONLY</b> on nodes that match <b>ALL</b>{' '}
|
||||
Rules
|
||||
</>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'placement_soft',
|
||||
value: KubernetesApplicationPlacementTypes.PREFERRED,
|
||||
icon: AlignJustify,
|
||||
iconType: 'badge',
|
||||
label: 'Preferred',
|
||||
description:
|
||||
'Schedule this application on nodes that match the rules if possible',
|
||||
},
|
||||
] as const;
|
|
@ -85,32 +85,7 @@
|
|||
<div class="col-sm-12 small text-muted"> Select the kind of data that you want to save in the configuration. </div>
|
||||
</div>
|
||||
|
||||
<!-- type options -->
|
||||
<div class="form-group px-[15px] mb-0">
|
||||
<div class="boxselector_wrapper">
|
||||
<div>
|
||||
<input type="radio" id="type_basic" ng-value="ctrl.KubernetesConfigurationKinds.CONFIGMAP" ng-model="ctrl.formValues.Kind" ng-change="ctrl.onChangeKind()" />
|
||||
<label for="type_basic" data-cy="k8sConfigCreate-nonSensitiveButton">
|
||||
<div class="boxselector_header">
|
||||
<pr-icon icon="'file-code'"></pr-icon>
|
||||
ConfigMap
|
||||
</div>
|
||||
<p>This configuration holds non-sensitive information</p>
|
||||
</label>
|
||||
</div>
|
||||
<div>
|
||||
<input type="radio" id="type_secret" ng-value="ctrl.KubernetesConfigurationKinds.SECRET" ng-model="ctrl.formValues.Kind" ng-change="ctrl.onChangeKind()" />
|
||||
<label for="type_secret" data-cy="k8sConfigCreate-sensitiveButton">
|
||||
<div class="boxselector_header">
|
||||
<pr-icon icon="'lock'"></pr-icon>
|
||||
Secret
|
||||
</div>
|
||||
<p>This configuration holds sensitive information</p>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- !type options -->
|
||||
<box-selector options="ctrl.typeOptions" value="ctrl.formValues.Kind" on-change="(ctrl.onChangeKind)" radio-name="'Kind'" slim="true"> </box-selector>
|
||||
|
||||
<div ng-if="ctrl.formValues.Kind === ctrl.KubernetesConfigurationKinds.SECRET">
|
||||
<div class="col-sm-12 form-section-title"> Information </div>
|
||||
|
|
|
@ -7,12 +7,14 @@ import KubernetesNamespaceHelper from 'Kubernetes/helpers/namespaceHelper';
|
|||
import { getServiceAccounts } from 'Kubernetes/rest/serviceAccount';
|
||||
|
||||
import { isConfigurationFormValid } from '../validation';
|
||||
import { typeOptions } from './options';
|
||||
|
||||
class KubernetesCreateConfigurationController {
|
||||
/* @ngInject */
|
||||
constructor($async, $state, $window, ModalService, Notifications, Authentication, KubernetesConfigurationService, KubernetesResourcePoolService, EndpointProvider) {
|
||||
constructor($async, $state, $scope, $window, ModalService, Notifications, Authentication, KubernetesConfigurationService, KubernetesResourcePoolService, EndpointProvider) {
|
||||
this.$async = $async;
|
||||
this.$state = $state;
|
||||
this.$scope = $scope;
|
||||
this.$window = $window;
|
||||
this.EndpointProvider = EndpointProvider;
|
||||
this.ModalService = ModalService;
|
||||
|
@ -23,11 +25,14 @@ class KubernetesCreateConfigurationController {
|
|||
this.KubernetesConfigurationKinds = KubernetesConfigurationKinds;
|
||||
this.KubernetesSecretTypeOptions = KubernetesSecretTypeOptions;
|
||||
|
||||
this.typeOptions = typeOptions;
|
||||
|
||||
this.onInit = this.onInit.bind(this);
|
||||
this.createConfigurationAsync = this.createConfigurationAsync.bind(this);
|
||||
this.getConfigurationsAsync = this.getConfigurationsAsync.bind(this);
|
||||
this.onResourcePoolSelectionChangeAsync = this.onResourcePoolSelectionChangeAsync.bind(this);
|
||||
this.onSecretTypeChange = this.onSecretTypeChange.bind(this);
|
||||
this.onChangeKind = this.onChangeKind.bind(this);
|
||||
}
|
||||
|
||||
onChangeName() {
|
||||
|
@ -38,18 +43,21 @@ class KubernetesCreateConfigurationController {
|
|||
this.state.alreadyExist = _.find(filteredConfigurations, (config) => config.Name === this.formValues.Name) !== undefined;
|
||||
}
|
||||
|
||||
onChangeKind() {
|
||||
this.onChangeName();
|
||||
// if there is no data field, add one
|
||||
if (this.formValues.Data.length === 0) {
|
||||
this.formValues.Data.push(new KubernetesConfigurationFormValuesEntry());
|
||||
}
|
||||
// if changing back to a secret, that is a service account token, remove the data field
|
||||
if (this.formValues.Kind === this.KubernetesConfigurationKinds.SECRET) {
|
||||
this.onSecretTypeChange();
|
||||
} else {
|
||||
this.isDockerConfig = false;
|
||||
}
|
||||
onChangeKind(value) {
|
||||
this.$scope.$evalAsync(() => {
|
||||
this.formValues.Kind = value;
|
||||
this.onChangeName();
|
||||
// if there is no data field, add one
|
||||
if (this.formValues.Data.length === 0) {
|
||||
this.formValues.Data.push(new KubernetesConfigurationFormValuesEntry());
|
||||
}
|
||||
// if changing back to a secret, that is a service account token, remove the data field
|
||||
if (this.formValues.Kind === this.KubernetesConfigurationKinds.SECRET) {
|
||||
this.onSecretTypeChange();
|
||||
} else {
|
||||
this.isDockerConfig = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async onResourcePoolSelectionChangeAsync() {
|
||||
|
|
|
@ -0,0 +1,23 @@
|
|||
import { KubernetesConfigurationKinds } from 'Kubernetes/models/configuration/models';
|
||||
import { FileCode, Lock } from 'lucide-react';
|
||||
|
||||
import { BoxSelectorOption } from '@@/BoxSelector';
|
||||
|
||||
export const typeOptions: ReadonlyArray<BoxSelectorOption<number>> = [
|
||||
{
|
||||
id: 'type_basic',
|
||||
value: KubernetesConfigurationKinds.CONFIGMAP,
|
||||
icon: FileCode,
|
||||
iconType: 'badge',
|
||||
label: 'ConfigMap',
|
||||
description: 'This configuration holds non-sensitive information',
|
||||
},
|
||||
{
|
||||
id: 'type_secret',
|
||||
value: KubernetesConfigurationKinds.SECRET,
|
||||
icon: Lock,
|
||||
iconType: 'badge',
|
||||
label: 'Secret',
|
||||
description: 'This configuration holds sensitive information',
|
||||
},
|
||||
] as const;
|
|
@ -56,6 +56,7 @@
|
|||
|
||||
<div class="col-sm-12 form-section-title"> Build method </div>
|
||||
<box-selector
|
||||
slim="true"
|
||||
radio-name="'method'"
|
||||
value="ctrl.state.BuildMethod"
|
||||
options="ctrl.methodOptions"
|
||||
|
|
|
@ -8,7 +8,7 @@ import { KubernetesDeployManifestTypes, KubernetesDeployBuildMethods, Kubernetes
|
|||
import { renderTemplate } from '@/react/portainer/custom-templates/components/utils';
|
||||
import { isBE } from '@/react/portainer/feature-flags/feature-flags.service';
|
||||
import { kubernetes } from '@@/BoxSelector/common-options/deployment-methods';
|
||||
import { editor, git, template, url } from '@@/BoxSelector/common-options/build-methods';
|
||||
import { editor, git, customTemplate, url } from '@@/BoxSelector/common-options/build-methods';
|
||||
|
||||
class KubernetesDeployController {
|
||||
/* @ngInject */
|
||||
|
@ -33,7 +33,7 @@ class KubernetesDeployController {
|
|||
{ ...git, value: KubernetesDeployBuildMethods.GIT },
|
||||
{ ...editor, value: KubernetesDeployBuildMethods.WEB_EDITOR },
|
||||
{ ...url, value: KubernetesDeployBuildMethods.URL },
|
||||
{ ...template, description: 'Use custom template', value: KubernetesDeployBuildMethods.CUSTOM_TEMPLATE },
|
||||
{ ...customTemplate, value: KubernetesDeployBuildMethods.CUSTOM_TEMPLATE },
|
||||
];
|
||||
|
||||
this.state = {
|
||||
|
|
|
@ -35,12 +35,14 @@ export const BoxSelectorAngular: IComponentOptions = {
|
|||
on-change="$ctrl.handleChange"
|
||||
options="$ctrl.options"
|
||||
radio-name="$ctrl.radioName"
|
||||
slim="$ctrl.slim"
|
||||
></box-selector-react>`,
|
||||
bindings: {
|
||||
value: '<',
|
||||
onChange: '<',
|
||||
options: '<',
|
||||
radioName: '<',
|
||||
slim: '<',
|
||||
},
|
||||
require: {
|
||||
formCtrl: '^form',
|
||||
|
|
|
@ -8,10 +8,12 @@ import { BoxSelectorAngular } from './BoxSelectorAngular';
|
|||
|
||||
export { buildOption } from './utils';
|
||||
const BoxSelectorReact = react2angular(BoxSelector, [
|
||||
'isMulti',
|
||||
'value',
|
||||
'onChange',
|
||||
'options',
|
||||
'radioName',
|
||||
'slim',
|
||||
]);
|
||||
|
||||
export const boxSelectorModule = angular
|
||||
|
|
|
@ -4,11 +4,11 @@ import { BoxSelectorOption } from '@@/BoxSelector/types';
|
|||
import { IconProps } from '@@/Icon';
|
||||
|
||||
export function buildOption<T extends number | string>(
|
||||
id: string,
|
||||
id: BoxSelectorOption<T>['id'],
|
||||
icon: IconProps['icon'],
|
||||
label: string,
|
||||
description: string,
|
||||
value: T,
|
||||
label: BoxSelectorOption<T>['label'],
|
||||
description: BoxSelectorOption<T>['description'],
|
||||
value: BoxSelectorOption<T>['value'],
|
||||
feature?: FeatureId
|
||||
): BoxSelectorOption<T> {
|
||||
return { id, icon, label, description, value, feature };
|
||||
|
|
|
@ -15,57 +15,16 @@
|
|||
</div>
|
||||
<!-- !access-control-switch -->
|
||||
<!-- restricted-access -->
|
||||
<div class="form-group" ng-if="$ctrl.formData.AccessControlEnabled">
|
||||
<div class="col-sm-12">
|
||||
<div class="boxselector_wrapper">
|
||||
<div ng-if="$ctrl.isAdmin">
|
||||
<input type="radio" id="access_administrators" ng-model="$ctrl.formData.Ownership" value="administrators" />
|
||||
<label for="access_administrators" data-cy="portainer-selectAdminAccess">
|
||||
<div class="boxselector_header">
|
||||
<pr-icon icon="'eye-off'"></pr-icon>
|
||||
Administrators
|
||||
</div>
|
||||
<p class="boxselector_content">I want to restrict the management of this resource to administrators only</p>
|
||||
</label>
|
||||
</div>
|
||||
<div ng-if="$ctrl.isAdmin">
|
||||
<input type="radio" id="access_restricted" ng-model="$ctrl.formData.Ownership" value="restricted" />
|
||||
<label for="access_restricted" data-cy="portainer-selectRestrictedAccess">
|
||||
<div class="boxselector_header">
|
||||
<pr-icon icon="'users'"></pr-icon>
|
||||
Restricted
|
||||
</div>
|
||||
<p class="boxselector_content"> I want to restrict the management of this resource to a set of users and/or teams </p>
|
||||
</label>
|
||||
</div>
|
||||
<div ng-if="!$ctrl.isAdmin">
|
||||
<input type="radio" id="access_private" ng-model="$ctrl.formData.Ownership" value="private" />
|
||||
<label for="access_private">
|
||||
<div class="boxselector_header">
|
||||
<pr-icon icon="'eye-off'"></pr-icon>
|
||||
Private
|
||||
</div>
|
||||
<p> I want to restrict this resource to be manageable by myself only </p>
|
||||
</label>
|
||||
</div>
|
||||
<div ng-if="!$ctrl.isAdmin && $ctrl.availableTeams.length > 0">
|
||||
<input type="radio" id="access_restricted" ng-model="$ctrl.formData.Ownership" value="restricted" />
|
||||
<label for="access_restricted">
|
||||
<div class="boxselector_header">
|
||||
<pr-icon icon="'users'"></pr-icon>
|
||||
|
||||
Restricted
|
||||
</div>
|
||||
<p ng-if="$ctrl.availableTeams.length === 1">
|
||||
I want any member of my team (<b>{{ $ctrl.availableTeams[0].Name }}</b
|
||||
>) to be able to manage this resource
|
||||
</p>
|
||||
<p ng-if="$ctrl.availableTeams.length > 1"> I want to restrict the management of this resource to one or more of my teams </p>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<access-type-selector
|
||||
ng-if="$ctrl.formData.AccessControlEnabled"
|
||||
value="$ctrl.formData.Ownership"
|
||||
on-change="($ctrl.onChangeOwnership)"
|
||||
is-admin="$ctrl.isAdmin"
|
||||
name="Ownership"
|
||||
teams="$ctrl.availableTeams"
|
||||
></access-type-selector>
|
||||
|
||||
<!-- restricted-access -->
|
||||
<!-- authorized-teams -->
|
||||
<div
|
||||
|
|
|
@ -21,6 +21,13 @@ angular.module('portainer.app').controller('porAccessControlFormController', [
|
|||
ctrl.availableTeams = [];
|
||||
ctrl.availableUsers = [];
|
||||
|
||||
ctrl.onChangeEnablement = onChangeEnablement;
|
||||
ctrl.onChangeOwnership = onChangeOwnership;
|
||||
|
||||
function onChangeOwnership(ownership) {
|
||||
onChange({ Ownership: ownership });
|
||||
}
|
||||
|
||||
function setOwnership(resourceControl, isAdmin) {
|
||||
if (isAdmin && resourceControl.Ownership === RCO.PRIVATE) {
|
||||
ctrl.formData.Ownership = RCO.RESTRICTED;
|
||||
|
@ -88,15 +95,11 @@ angular.module('portainer.app').controller('porAccessControlFormController', [
|
|||
.catch(function error(err) {
|
||||
Notifications.error('Failure', err, 'Unable to retrieve access control information');
|
||||
});
|
||||
}
|
||||
|
||||
this.onChangeEnablement = function (enable) {
|
||||
$scope.$evalAsync(() => {
|
||||
ctrl.formData.AccessControlEnabled = enable;
|
||||
if (enable) {
|
||||
ctrl.formData.Ownership = isAdmin ? RCO.ADMINISTRATORS : RCO.PRIVATE;
|
||||
}
|
||||
});
|
||||
};
|
||||
function onChangeEnablement(enable) {
|
||||
const isAdmin = Authentication.isAdmin();
|
||||
onChange({ AccessControlEnabled: enable, Ownership: isAdmin ? RCO.ADMINISTRATORS : RCO.PRIVATE });
|
||||
}
|
||||
},
|
||||
]);
|
||||
|
|
|
@ -22,55 +22,16 @@
|
|||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group"></div>
|
||||
<!-- endpoint-tls-mode -->
|
||||
<div class="form-group" ng-if="$ctrl.formData.TLS">
|
||||
<div class="col-sm-12">
|
||||
<div class="boxselector_wrapper">
|
||||
<div>
|
||||
<input type="radio" id="tls_client_ca" ng-model="$ctrl.formData.TLSMode" value="tls_client_ca" />
|
||||
<label for="tls_client_ca">
|
||||
<div class="boxselector_header">
|
||||
<pr-icon icon="'shield'"></pr-icon>
|
||||
TLS with server and client verification
|
||||
</div>
|
||||
<p>Use client certificates and server verification</p>
|
||||
</label>
|
||||
</div>
|
||||
<div>
|
||||
<input type="radio" id="tls_client_noca" ng-model="$ctrl.formData.TLSMode" value="tls_client_noca" />
|
||||
<label for="tls_client_noca">
|
||||
<div class="boxselector_header">
|
||||
<pr-icon icon="'shield'"></pr-icon>
|
||||
TLS with client verification only
|
||||
</div>
|
||||
<p>Use client certificates without server verification</p>
|
||||
</label>
|
||||
</div>
|
||||
<div>
|
||||
<input type="radio" id="tls_ca" ng-model="$ctrl.formData.TLSMode" value="tls_ca" />
|
||||
<label for="tls_ca">
|
||||
<div class="boxselector_header">
|
||||
<pr-icon icon="'shield'"></pr-icon>
|
||||
TLS with server verification only
|
||||
</div>
|
||||
<p>Only verify the server certificate</p>
|
||||
</label>
|
||||
</div>
|
||||
<div>
|
||||
<input type="radio" id="tls_only" ng-model="$ctrl.formData.TLSMode" value="tls_only" />
|
||||
<label for="tls_only">
|
||||
<div class="boxselector_header">
|
||||
<pr-icon icon="'shield'"></pr-icon>
|
||||
TLS only
|
||||
</div>
|
||||
<p>No server/client verification</p>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- !endpoint-tls-mode -->
|
||||
|
||||
<box-selector
|
||||
ng-if="$ctrl.formData.TLS"
|
||||
slim="true"
|
||||
radio-name="'tls_mode'"
|
||||
options="$ctrl.tlsOptions"
|
||||
value="$ctrl.formData.TLSMode"
|
||||
on-change="($ctrl.onChangeTLSMode)"
|
||||
></box-selector>
|
||||
|
||||
<div class="col-sm-12 form-section-title" ng-if="$ctrl.formData.TLS && $ctrl.formData.TLSMode !== 'tls_only'"> Required TLS files </div>
|
||||
<!-- tls-file-upload -->
|
||||
<div ng-if="$ctrl.formData.TLS">
|
||||
|
|
|
@ -1,13 +1,30 @@
|
|||
import { tlsOptions } from './tls-options';
|
||||
|
||||
angular.module('portainer.app').controller('porEndpointSecurityController', [
|
||||
'$scope',
|
||||
function ($scope) {
|
||||
var ctrl = this;
|
||||
|
||||
ctrl.onToggleTLS = function (newValue) {
|
||||
this.tlsOptions = tlsOptions;
|
||||
|
||||
function onChange(values) {
|
||||
$scope.$evalAsync(() => {
|
||||
ctrl.formData.TLS = newValue;
|
||||
ctrl.formData = {
|
||||
...ctrl.formData,
|
||||
...values,
|
||||
};
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
ctrl.onChangeTLSMode = onChangeTLSMode;
|
||||
function onChangeTLSMode(mode) {
|
||||
onChange({ TLSMode: mode });
|
||||
}
|
||||
|
||||
ctrl.onToggleTLS = onToggleTLS;
|
||||
function onToggleTLS(newValue) {
|
||||
onChange({ TLS: newValue });
|
||||
}
|
||||
|
||||
this.$onInit = $onInit;
|
||||
function $onInit() {
|
||||
|
|
|
@ -0,0 +1,38 @@
|
|||
import { Shield } from 'lucide-react';
|
||||
|
||||
import { BoxSelectorOption } from '@@/BoxSelector';
|
||||
|
||||
export const tlsOptions: ReadonlyArray<BoxSelectorOption<string>> = [
|
||||
{
|
||||
id: 'tls_client_ca',
|
||||
value: 'tls_client_ca',
|
||||
icon: Shield,
|
||||
iconType: 'badge',
|
||||
label: 'TLS with server and client verification',
|
||||
description: 'Use client certificates and server verification',
|
||||
},
|
||||
{
|
||||
id: 'tls_client_noca',
|
||||
value: 'tls_client_noca',
|
||||
icon: Shield,
|
||||
iconType: 'badge',
|
||||
label: 'TLS with client verification only',
|
||||
description: 'Use client certificates without server verification',
|
||||
},
|
||||
{
|
||||
id: 'tls_ca',
|
||||
value: 'tls_ca',
|
||||
icon: Shield,
|
||||
iconType: 'badge',
|
||||
label: 'TLS with server verification only',
|
||||
description: 'Only verify the server certificate',
|
||||
},
|
||||
{
|
||||
id: 'tls_only',
|
||||
value: 'tls_only',
|
||||
icon: Shield,
|
||||
iconType: 'badge',
|
||||
label: 'TLS only',
|
||||
description: 'No server/client verification',
|
||||
},
|
||||
] as const;
|
|
@ -18,6 +18,7 @@ import { InternalAuth } from '@/react/portainer/settings/AuthenticationView/Inte
|
|||
import { PorAccessControlFormTeamSelector } from '@/react/portainer/access-control/PorAccessControlForm/TeamsSelector';
|
||||
import { PorAccessControlFormUserSelector } from '@/react/portainer/access-control/PorAccessControlForm/UsersSelector';
|
||||
import { PorAccessManagementUsersSelector } from '@/react/portainer/access-control/AccessManagement/PorAccessManagementUsersSelector';
|
||||
import { AccessTypeSelector } from '@/react/portainer/access-control/EditDetails/AccessTypeSelector';
|
||||
|
||||
import { PageHeader } from '@@/PageHeader';
|
||||
import { TagSelector } from '@@/TagSelector';
|
||||
|
@ -66,6 +67,17 @@ export const componentsModule = angular
|
|||
'tagButton',
|
||||
r2a(TagButton, ['value', 'label', 'title', 'onRemove'])
|
||||
)
|
||||
.component(
|
||||
'accessTypeSelector',
|
||||
r2a(AccessTypeSelector, [
|
||||
'isAdmin',
|
||||
'isPublicVisible',
|
||||
'name',
|
||||
'onChange',
|
||||
'value',
|
||||
'teams',
|
||||
])
|
||||
)
|
||||
.component(
|
||||
'portainerTooltip',
|
||||
r2a(Tooltip, ['message', 'position', 'className', 'setHtmlMessage'])
|
||||
|
|
|
@ -15,42 +15,15 @@
|
|||
<!-- build-method -->
|
||||
<div ng-if="!$ctrl.state.fromStack">
|
||||
<div class="col-sm-12 form-section-title"> Build method </div>
|
||||
<div class="form-group">
|
||||
<div class="col-sm-12">
|
||||
<div class="boxselector_wrapper">
|
||||
<div>
|
||||
<input type="radio" id="method_editor" ng-model="$ctrl.state.Method" value="editor" ng-change="$ctrl.onChangeMethod()" />
|
||||
<label for="method_editor">
|
||||
<div class="boxselector_header">
|
||||
<pr-icon icon="'edit'"></pr-icon>
|
||||
Web editor
|
||||
</div>
|
||||
<p>Use our Web editor</p>
|
||||
</label>
|
||||
</div>
|
||||
<div>
|
||||
<input type="radio" id="method_upload" ng-model="$ctrl.state.Method" value="upload" ng-change="$ctrl.onChangeMethod()" />
|
||||
<label for="method_upload">
|
||||
<div class="boxselector_header">
|
||||
<pr-icon icon="'upload'"></pr-icon>
|
||||
Upload
|
||||
</div>
|
||||
<p>Upload from your computer</p>
|
||||
</label>
|
||||
</div>
|
||||
<div>
|
||||
<input type="radio" id="method_repository" ng-model="$ctrl.state.Method" value="repository" ng-change="$ctrl.onChangeMethod()" />
|
||||
<label for="method_repository">
|
||||
<div class="boxselector_header">
|
||||
<pr-icon icon="'git-pull-request'"></pr-icon>
|
||||
Repository
|
||||
</div>
|
||||
<p>Use a git repository</p>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<box-selector
|
||||
slim="true"
|
||||
options="$ctrl.buildMethods"
|
||||
value="$ctrl.state.Method"
|
||||
on-change="($ctrl.onChangeMethod)"
|
||||
radio-name="'buildMethod'"
|
||||
slim="true"
|
||||
></box-selector>
|
||||
</div>
|
||||
<!-- !build-method -->
|
||||
<!-- web-editor -->
|
||||
|
|
|
@ -3,14 +3,29 @@ import { AccessControlFormData } from 'Portainer/components/accessControlForm/po
|
|||
import { TEMPLATE_NAME_VALIDATION_REGEX } from '@/constants';
|
||||
import { getTemplateVariables, intersectVariables } from '@/react/portainer/custom-templates/components/utils';
|
||||
import { isBE } from '@/react/portainer/feature-flags/feature-flags.service';
|
||||
import { editor, upload, git } from '@@/BoxSelector/common-options/build-methods';
|
||||
|
||||
class CreateCustomTemplateViewController {
|
||||
/* @ngInject */
|
||||
constructor($async, $state, $window, Authentication, ModalService, CustomTemplateService, FormValidator, Notifications, ResourceControlService, StackService, StateManager) {
|
||||
constructor(
|
||||
$async,
|
||||
$state,
|
||||
$scope,
|
||||
$window,
|
||||
Authentication,
|
||||
ModalService,
|
||||
CustomTemplateService,
|
||||
FormValidator,
|
||||
Notifications,
|
||||
ResourceControlService,
|
||||
StackService,
|
||||
StateManager
|
||||
) {
|
||||
Object.assign(this, {
|
||||
$async,
|
||||
$state,
|
||||
$window,
|
||||
$scope,
|
||||
Authentication,
|
||||
ModalService,
|
||||
CustomTemplateService,
|
||||
|
@ -21,6 +36,8 @@ class CreateCustomTemplateViewController {
|
|||
StateManager,
|
||||
});
|
||||
|
||||
this.buildMethods = [editor, upload, git];
|
||||
|
||||
this.isTemplateVariablesEnabled = isBE;
|
||||
|
||||
this.formValues = {
|
||||
|
@ -85,10 +102,13 @@ class CreateCustomTemplateViewController {
|
|||
return this.$async(this.createCustomTemplateAsync);
|
||||
}
|
||||
|
||||
onChangeMethod() {
|
||||
this.formValues.FileContent = '';
|
||||
this.formValues.Variables = [];
|
||||
this.selectedTemplate = null;
|
||||
onChangeMethod(method) {
|
||||
return this.$scope.$evalAsync(() => {
|
||||
this.formValues.FileContent = '';
|
||||
this.formValues.Variables = [];
|
||||
this.selectedTemplate = null;
|
||||
this.state.Method = method;
|
||||
});
|
||||
}
|
||||
|
||||
async createCustomTemplateAsync() {
|
||||
|
|
|
@ -16,22 +16,8 @@
|
|||
<!-- !name-input -->
|
||||
<!-- build-method -->
|
||||
<div class="col-sm-12 form-section-title"> Profile configuration </div>
|
||||
<div class="form-group">
|
||||
<div class="col-sm-12">
|
||||
<div class="boxselector_wrapper">
|
||||
<div>
|
||||
<input type="radio" id="method_editor" ng-model="state.method" value="editor" />
|
||||
<label for="method_editor">
|
||||
<div class="boxselector_header">
|
||||
<pr-icon icon="'edit'"></pr-icon>
|
||||
Web editor
|
||||
</div>
|
||||
<p>Use our Web editor</p>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<box-selector slim="true" options="buildMethods" value="state.method"></box-selector>
|
||||
|
||||
<!-- !build-method -->
|
||||
|
||||
<web-editor-form
|
||||
|
|
|
@ -1,10 +1,13 @@
|
|||
import angular from 'angular';
|
||||
import { editor } from '@@/BoxSelector/common-options/build-methods';
|
||||
|
||||
import { createProfile } from 'Portainer/hostmanagement/fdo/fdo.service';
|
||||
|
||||
angular.module('portainer.app').controller('AddProfileController', AddProfileController);
|
||||
|
||||
export default function AddProfileController($scope, $async, $state, $window, ModalService, Authentication, Notifications) {
|
||||
$scope.buildMethods = [editor];
|
||||
|
||||
$scope.formValues = {
|
||||
name: '',
|
||||
profileFileContent: '',
|
||||
|
|
|
@ -16,23 +16,9 @@
|
|||
<!-- !name-input -->
|
||||
<!-- build-method -->
|
||||
<div class="col-sm-12 form-section-title"> Profile configuration </div>
|
||||
<div class="form-group"></div>
|
||||
<div class="form-group">
|
||||
<div class="col-sm-12">
|
||||
<div class="boxselector_wrapper">
|
||||
<div>
|
||||
<input type="radio" id="method_editor" ng-model="state.method" value="editor" />
|
||||
<label for="method_editor">
|
||||
<div class="boxselector_header">
|
||||
<pr-icon icon="'edit'"></pr-icon>
|
||||
Web editor
|
||||
</div>
|
||||
<p>Use our Web editor</p>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<box-selector slim="true" options="buildMethods" value="state.method"></box-selector>
|
||||
|
||||
<!-- !build-method -->
|
||||
|
||||
<web-editor-form
|
||||
|
|
|
@ -1,9 +1,12 @@
|
|||
import angular from 'angular';
|
||||
import { editor } from '@@/BoxSelector/common-options/build-methods';
|
||||
import { getProfile, updateProfile } from 'Portainer/hostmanagement/fdo/fdo.service';
|
||||
|
||||
angular.module('portainer.app').controller('EditProfileController', EditProfileController);
|
||||
|
||||
export default function EditProfileController($scope, $async, $state, $window, ModalService, Authentication, Notifications) {
|
||||
$scope.buildMethods = [editor];
|
||||
|
||||
$scope.formValues = {
|
||||
name: '',
|
||||
profileFileContent: '',
|
||||
|
|
|
@ -131,37 +131,9 @@
|
|||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<!-- !note -->
|
||||
<div class="form-group">
|
||||
<div class="col-sm-12">
|
||||
<div class="boxselector_wrapper">
|
||||
<div>
|
||||
<input type="radio" id="restore_file" checked="checked" />
|
||||
<label for="restore_file" data-cy="init-selectLocalFile">
|
||||
<div class="boxselector_header">
|
||||
<pr-icon icon="'upload'"></pr-icon>
|
||||
Upload backup file
|
||||
</div>
|
||||
<p></p>
|
||||
</label>
|
||||
</div>
|
||||
<div>
|
||||
<input type="radio" id="restore_s3" disabled />
|
||||
<label for="restore_s3" class="boxselector_disabled">
|
||||
<div class="boxselector_header">
|
||||
<pr-icon icon="'download'"></pr-icon>
|
||||
Retrieve from S3
|
||||
</div>
|
||||
<p
|
||||
>This feature is available in
|
||||
<a class="hyperlink" href="https://www.portainer.io/business-upsell?from=restore-s3-form" target="_blank"> Portainer Business Edition</a></p
|
||||
>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- note -->
|
||||
|
||||
<box-selector slim="true" options="restoreOptions" value="formValues.restoreFormType" on-change="(onChangeRestoreType)" radio-name="'restore-type'"></box-selector>
|
||||
|
||||
<div class="form-group">
|
||||
<div class="col-sm-12">
|
||||
<span class="small text-muted"> You can upload a backup file from your computer. </span>
|
||||
|
|
|
@ -1,4 +1,5 @@
|
|||
import { getEnvironments } from '@/react/portainer/environments/environment.service';
|
||||
import { restoreOptions } from './restore-options';
|
||||
|
||||
angular.module('portainer.app').controller('InitAdminController', [
|
||||
'$scope',
|
||||
|
@ -11,6 +12,8 @@ angular.module('portainer.app').controller('InitAdminController', [
|
|||
'BackupService',
|
||||
'StatusService',
|
||||
function ($scope, $state, Notifications, Authentication, StateManager, SettingsService, UserService, BackupService, StatusService) {
|
||||
$scope.restoreOptions = restoreOptions;
|
||||
|
||||
$scope.uploadBackup = uploadBackup;
|
||||
|
||||
$scope.logo = StateManager.getState().application.logo;
|
||||
|
@ -35,6 +38,13 @@ angular.module('portainer.app').controller('InitAdminController', [
|
|||
$scope.state.showRestorePortainer = !$scope.state.showRestorePortainer;
|
||||
};
|
||||
|
||||
$scope.onChangeRestoreType = onChangeRestoreType;
|
||||
function onChangeRestoreType(value) {
|
||||
$scope.$evalAsync(() => {
|
||||
$scope.formValues.restoreFormType = value;
|
||||
});
|
||||
}
|
||||
|
||||
$scope.createAdminUser = function () {
|
||||
var username = $scope.formValues.Username;
|
||||
var password = $scope.formValues.Password;
|
||||
|
|
|
@ -0,0 +1,23 @@
|
|||
import { Download, Upload } from 'lucide-react';
|
||||
|
||||
import { FeatureId } from '@/react/portainer/feature-flags/enums';
|
||||
|
||||
import { BoxSelectorOption } from '@@/BoxSelector';
|
||||
|
||||
export const restoreOptions: ReadonlyArray<BoxSelectorOption<string>> = [
|
||||
{
|
||||
id: 'restore_file',
|
||||
value: 'file',
|
||||
icon: Upload,
|
||||
iconType: 'badge',
|
||||
label: 'Upload backup file',
|
||||
},
|
||||
{
|
||||
id: 'restore_s3',
|
||||
value: 's3',
|
||||
icon: Download,
|
||||
iconType: 'badge',
|
||||
label: 'Retrieve from S3',
|
||||
feature: FeatureId.S3_RESTORE,
|
||||
},
|
||||
] as const;
|
|
@ -278,7 +278,7 @@
|
|||
<form class="form-horizontal" ng-submit="backupPortainer()" name="backupPortainerForm">
|
||||
<div class="col-sm-12 form-section-title"> Backup configuration </div>
|
||||
|
||||
<box-selector options="backupOptions" value="formValues.backupFormType" on-change="(onBackupOptionsChange)" radio-name="'backupOptions'"></box-selector>
|
||||
<box-selector slim="true" options="backupOptions" value="formValues.backupFormType" on-change="(onBackupOptionsChange)" radio-name="'backupOptions'"></box-selector>
|
||||
|
||||
<div ng-if="formValues.backupFormType === BACKUP_FORM_TYPES.S3">
|
||||
<!-- Schedule automatic backups -->
|
||||
|
|
|
@ -7,6 +7,7 @@ import { RepositoryMechanismTypes } from '@/kubernetes/models/deploy';
|
|||
import { FeatureId } from '@/react/portainer/feature-flags/enums';
|
||||
import { isBE } from '@/react/portainer/feature-flags/feature-flags.service';
|
||||
import { renderTemplate } from '@/react/portainer/custom-templates/components/utils';
|
||||
import { editor, upload, git, customTemplate } from '@@/BoxSelector/common-options/build-methods';
|
||||
|
||||
angular
|
||||
.module('portainer.app')
|
||||
|
@ -37,6 +38,7 @@ angular
|
|||
$scope.isTemplateVariablesEnabled = isBE;
|
||||
$scope.buildAnalyticsProperties = buildAnalyticsProperties;
|
||||
$scope.stackWebhookFeature = FeatureId.STACK_WEBHOOK;
|
||||
$scope.buildMethods = [editor, upload, git, customTemplate];
|
||||
$scope.STACK_NAME_VALIDATION_REGEX = STACK_NAME_VALIDATION_REGEX;
|
||||
$scope.isAdmin = Authentication.isAdmin();
|
||||
|
||||
|
@ -83,6 +85,13 @@ angular
|
|||
});
|
||||
|
||||
$scope.onChangeFormValues = onChangeFormValues;
|
||||
$scope.onBuildMethodChange = onBuildMethodChange;
|
||||
|
||||
function onBuildMethodChange(value) {
|
||||
$scope.$evalAsync(() => {
|
||||
$scope.state.Method = value;
|
||||
});
|
||||
}
|
||||
|
||||
$scope.onEnableWebhookChange = function (enable) {
|
||||
$scope.$evalAsync(() => {
|
||||
|
|
|
@ -49,52 +49,9 @@
|
|||
</div>
|
||||
<!-- build-method -->
|
||||
<div class="col-sm-12 form-section-title"> Build method </div>
|
||||
<div class="form-group">
|
||||
<div class="col-sm-12">
|
||||
<div class="boxselector_wrapper">
|
||||
<div>
|
||||
<input type="radio" id="method_editor" ng-model="state.Method" value="editor" />
|
||||
<label for="method_editor">
|
||||
<div class="boxselector_header">
|
||||
<pr-icon icon="'edit'"></pr-icon>
|
||||
Web editor
|
||||
</div>
|
||||
<p>Use our Web editor</p>
|
||||
</label>
|
||||
</div>
|
||||
<div>
|
||||
<input type="radio" id="method_upload" ng-model="state.Method" value="upload" />
|
||||
<label for="method_upload">
|
||||
<div class="boxselector_header">
|
||||
<pr-icon icon="'upload'"></pr-icon>
|
||||
Upload
|
||||
</div>
|
||||
<p>Upload from your computer</p>
|
||||
</label>
|
||||
</div>
|
||||
<div>
|
||||
<input type="radio" id="method_repository" ng-model="state.Method" value="repository" />
|
||||
<label for="method_repository">
|
||||
<div class="boxselector_header">
|
||||
<pr-icon icon="'git-pull-request'"></pr-icon>
|
||||
Repository
|
||||
</div>
|
||||
<p>Use a git repository</p>
|
||||
</label>
|
||||
</div>
|
||||
<div>
|
||||
<input type="radio" id="method_template" ng-model="state.Method" value="template" />
|
||||
<label for="method_template">
|
||||
<div class="boxselector_header">
|
||||
<pr-icon icon="'edit'"></pr-icon>
|
||||
Custom template
|
||||
</div>
|
||||
<p>Use a custom template</p>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<box-selector slim="true" radio-name="'build-method'" options="buildMethods" value="state.Method" on-change="(onBuildMethodChange)" slim="true"></box-selector>
|
||||
|
||||
<!-- !build-method -->
|
||||
|
||||
<!-- upload -->
|
||||
|
|
|
@ -0,0 +1,52 @@
|
|||
.root input {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.root label {
|
||||
@apply border border-solid;
|
||||
@apply bg-gray-2 border-gray-5 text-black;
|
||||
@apply th-dark:bg-gray-iron-10 th-dark:border-gray-neutral-8 th-dark:text-white;
|
||||
@apply th-highcontrast:text-white;
|
||||
|
||||
font-weight: normal;
|
||||
font-size: 12px;
|
||||
display: block;
|
||||
border-radius: 8px;
|
||||
padding: 15px;
|
||||
text-align: left;
|
||||
box-shadow: var(--shadow-boxselector-color);
|
||||
position: relative;
|
||||
|
||||
text-align: left;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
/* not disabled */
|
||||
.root input:not(:disabled) ~ label {
|
||||
@apply bg-gray-2;
|
||||
@apply th-dark:bg-gray-iron-10;
|
||||
@apply th-highcontrast:bg-black;
|
||||
|
||||
box-shadow: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* disabled */
|
||||
.root input:disabled + label {
|
||||
@apply bg-white;
|
||||
@apply th-dark:bg-gray-7;
|
||||
@apply th-highcontrast:bg-black;
|
||||
filter: opacity(0.3) grayscale(1);
|
||||
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.root input:checked + label {
|
||||
@apply bg-blue-2 border-blue-6;
|
||||
@apply th-dark:bg-blue-10 th-dark:border-blue-7;
|
||||
@apply th-highcontrast:bg-blue-10 th-highcontrast:border-blue-7;
|
||||
|
||||
border-radius: 8px;
|
||||
padding: 15px;
|
||||
box-shadow: none;
|
||||
}
|
|
@ -1,56 +1,73 @@
|
|||
import clsx from 'clsx';
|
||||
import { PropsWithChildren } from 'react';
|
||||
import type { Icon } from 'lucide-react';
|
||||
|
||||
import { TooltipWithChildren } from '@@/Tip/TooltipWithChildren';
|
||||
|
||||
import './BoxSelectorItem.css';
|
||||
import styles from './BoxOption.module.css';
|
||||
import { BoxSelectorOption, Value } from './types';
|
||||
|
||||
import { BoxSelectorOption } from './types';
|
||||
|
||||
interface Props<T extends number | string> {
|
||||
interface Props<T extends Value> {
|
||||
radioName: string;
|
||||
option: BoxSelectorOption<T>;
|
||||
onChange?(value: T): void;
|
||||
selectedValue: T;
|
||||
onSelect?(value: T): void;
|
||||
isSelected(value: T): boolean;
|
||||
disabled?: boolean;
|
||||
tooltip?: string;
|
||||
className?: string;
|
||||
type?: 'radio' | 'checkbox';
|
||||
checkIcon: Icon;
|
||||
}
|
||||
|
||||
export function BoxOption<T extends number | string>({
|
||||
export function BoxOption<T extends Value>({
|
||||
radioName,
|
||||
option,
|
||||
onChange = () => {},
|
||||
selectedValue,
|
||||
onSelect = () => {},
|
||||
isSelected,
|
||||
disabled,
|
||||
tooltip,
|
||||
className,
|
||||
type = 'radio',
|
||||
children,
|
||||
checkIcon: Check,
|
||||
}: PropsWithChildren<Props<T>>) {
|
||||
const BoxOption = (
|
||||
<div className={clsx('box-selector-item', className)}>
|
||||
const selected = isSelected(option.value);
|
||||
|
||||
const item = (
|
||||
<div className={clsx(styles.root, className)}>
|
||||
<input
|
||||
type={type}
|
||||
name={radioName}
|
||||
id={option.id}
|
||||
checked={option.value === selectedValue}
|
||||
value={option.value}
|
||||
checked={selected}
|
||||
value={option.value.toString()}
|
||||
disabled={disabled}
|
||||
onChange={() => onChange(option.value)}
|
||||
onChange={() => onSelect(option.value)}
|
||||
/>
|
||||
|
||||
<label htmlFor={option.id} data-cy={`${radioName}_${option.value}`}>
|
||||
{children}
|
||||
|
||||
{!disabled && (
|
||||
<div
|
||||
className={clsx(
|
||||
'absolute top-4 right-4 h-4 w-4 rounded-full border border-solid border-blue-8 text-white font-bold flex items-center justify-center',
|
||||
{
|
||||
'bg-white': !selected,
|
||||
'bg-blue-8': selected,
|
||||
}
|
||||
)}
|
||||
>
|
||||
{selected && <Check className="lucide" strokeWidth={3} />}
|
||||
</div>
|
||||
)}
|
||||
</label>
|
||||
</div>
|
||||
);
|
||||
|
||||
if (tooltip) {
|
||||
return (
|
||||
<TooltipWithChildren message={tooltip}>{BoxOption}</TooltipWithChildren>
|
||||
);
|
||||
return <TooltipWithChildren message={tooltip}>{item}</TooltipWithChildren>;
|
||||
}
|
||||
return BoxOption;
|
||||
|
||||
return item;
|
||||
}
|
||||
|
|
|
@ -1,14 +0,0 @@
|
|||
.boxselector_wrapper {
|
||||
display: flex;
|
||||
flex-flow: row wrap;
|
||||
gap: 10px;
|
||||
overflow: hidden !important;
|
||||
margin-bottom: 5px;
|
||||
margin-top: 5px;
|
||||
}
|
||||
|
||||
@media only screen and (max-width: 700px) {
|
||||
.boxselector_wrapper {
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
|
@ -1,4 +1,16 @@
|
|||
.root {
|
||||
width: 100%;
|
||||
overflow: auto;
|
||||
display: flex;
|
||||
flex-flow: row wrap;
|
||||
gap: 10px;
|
||||
overflow: hidden !important;
|
||||
margin-bottom: 5px;
|
||||
margin-top: 5px;
|
||||
}
|
||||
|
||||
@media only screen and (max-width: 700px) {
|
||||
.root {
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,7 +1,8 @@
|
|||
import { Meta } from '@storybook/react';
|
||||
import { useState } from 'react';
|
||||
import { User } from 'lucide-react';
|
||||
import { Anchor, Briefcase } from 'lucide-react';
|
||||
|
||||
import Docker from '@/assets/ico/vendor/docker.svg?c';
|
||||
import { init as initFeatureService } from '@/react/portainer/feature-flags/feature-flags.service';
|
||||
import { Edition, FeatureId } from '@/react/portainer/feature-flags/enums';
|
||||
|
||||
|
@ -22,14 +23,16 @@ function Example() {
|
|||
const options: BoxSelectorOption<number>[] = [
|
||||
{
|
||||
description: 'description 1',
|
||||
icon: User,
|
||||
icon: Anchor,
|
||||
iconType: 'badge',
|
||||
id: '1',
|
||||
value: 3,
|
||||
label: 'option 1',
|
||||
},
|
||||
{
|
||||
description: 'description 2',
|
||||
icon: User,
|
||||
icon: Briefcase,
|
||||
iconType: 'badge',
|
||||
id: '2',
|
||||
value: 4,
|
||||
label: 'option 2',
|
||||
|
@ -54,14 +57,16 @@ function LimitedFeature() {
|
|||
const options: BoxSelectorOption<number>[] = [
|
||||
{
|
||||
description: 'description 1',
|
||||
icon: User,
|
||||
icon: Anchor,
|
||||
iconType: 'badge',
|
||||
id: '1',
|
||||
value: 3,
|
||||
label: 'option 1',
|
||||
},
|
||||
{
|
||||
description: 'description 2',
|
||||
icon: User,
|
||||
icon: Briefcase,
|
||||
iconType: 'badge',
|
||||
id: '2',
|
||||
value: 4,
|
||||
label: 'option 2',
|
||||
|
@ -81,6 +86,85 @@ function LimitedFeature() {
|
|||
);
|
||||
}
|
||||
|
||||
// regular example
|
||||
export function MultiSelect() {
|
||||
const [value, setValue] = useState([3]);
|
||||
const options: BoxSelectorOption<number>[] = [
|
||||
{
|
||||
description: 'description 1',
|
||||
icon: Anchor,
|
||||
iconType: 'badge',
|
||||
id: '1',
|
||||
value: 1,
|
||||
label: 'option 1',
|
||||
},
|
||||
{
|
||||
description: 'description 2',
|
||||
icon: Briefcase,
|
||||
iconType: 'badge',
|
||||
id: '2',
|
||||
value: 2,
|
||||
label: 'option 2',
|
||||
},
|
||||
{
|
||||
description: 'description 3',
|
||||
icon: Docker,
|
||||
id: '3',
|
||||
value: 3,
|
||||
label: 'option 2',
|
||||
},
|
||||
];
|
||||
|
||||
// story with limited feature
|
||||
return (
|
||||
<BoxSelector
|
||||
isMulti
|
||||
radioName="name"
|
||||
onChange={(value: number[]) => {
|
||||
setValue(value);
|
||||
}}
|
||||
value={value}
|
||||
options={options}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function SlimMultiSelect() {
|
||||
const [value, setValue] = useState([3]);
|
||||
const options: BoxSelectorOption<number>[] = [
|
||||
{
|
||||
description: 'description 1',
|
||||
icon: Anchor,
|
||||
iconType: 'badge',
|
||||
id: '1',
|
||||
value: 1,
|
||||
label: 'option 1',
|
||||
},
|
||||
{
|
||||
description: 'description 2',
|
||||
icon: Briefcase,
|
||||
iconType: 'badge',
|
||||
id: '2',
|
||||
value: 2,
|
||||
label: 'option 2',
|
||||
},
|
||||
{
|
||||
description: 'description 3',
|
||||
icon: Docker,
|
||||
id: '3',
|
||||
value: 3,
|
||||
label: 'option 2',
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<BoxSelector
|
||||
isMulti
|
||||
radioName="name"
|
||||
onChange={(value: number[]) => {
|
||||
setValue(value);
|
||||
}}
|
||||
value={value}
|
||||
options={options}
|
||||
slim
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -2,21 +2,26 @@ import { Rocket } from 'lucide-react';
|
|||
|
||||
import { render, fireEvent } from '@/react-tools/test-utils';
|
||||
|
||||
import { BoxSelector, Props } from './BoxSelector';
|
||||
import { BoxSelectorOption } from './types';
|
||||
import { BoxSelector } from './BoxSelector';
|
||||
import { BoxSelectorOption, Value } from './types';
|
||||
|
||||
function renderDefault<T extends string | number>({
|
||||
function renderDefault<T extends Value>({
|
||||
options = [],
|
||||
onChange = () => {},
|
||||
radioName = 'radio',
|
||||
value,
|
||||
}: Partial<Props<T>> = {}) {
|
||||
}: {
|
||||
options?: BoxSelectorOption<T>[];
|
||||
onChange?: (value: T) => void;
|
||||
radioName?: string;
|
||||
value: T;
|
||||
}) {
|
||||
return render(
|
||||
<BoxSelector
|
||||
options={options}
|
||||
onChange={onChange}
|
||||
radioName={radioName}
|
||||
value={value || 0}
|
||||
value={value}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -1,30 +1,39 @@
|
|||
import clsx from 'clsx';
|
||||
import { Check, Minus } from 'lucide-react';
|
||||
|
||||
import './BoxSelector.css';
|
||||
import styles from './BoxSelector.module.css';
|
||||
import { BoxSelectorItem } from './BoxSelectorItem';
|
||||
import { BoxSelectorOption } from './types';
|
||||
import { BoxSelectorOption, Value } from './types';
|
||||
|
||||
export interface Props<T extends number | string> {
|
||||
radioName: string;
|
||||
value: T;
|
||||
onChange(value: T, limitedToBE: boolean): void;
|
||||
options: BoxSelectorOption<T>[];
|
||||
interface IsMultiProps<T extends Value> {
|
||||
isMulti: true;
|
||||
value: T[];
|
||||
onChange(value: T[], limitedToBE: boolean): void;
|
||||
}
|
||||
|
||||
export function BoxSelector<T extends number | string>({
|
||||
interface SingleProps<T extends Value> {
|
||||
isMulti?: never;
|
||||
value: T;
|
||||
onChange(value: T, limitedToBE: boolean): void;
|
||||
}
|
||||
|
||||
type Union<T extends Value> = IsMultiProps<T> | SingleProps<T>;
|
||||
|
||||
export type Props<T extends Value> = Union<T> & {
|
||||
radioName: string;
|
||||
options: ReadonlyArray<BoxSelectorOption<T>> | Array<BoxSelectorOption<T>>;
|
||||
slim?: boolean;
|
||||
};
|
||||
|
||||
export function BoxSelector<T extends Value>({
|
||||
radioName,
|
||||
value,
|
||||
options,
|
||||
onChange,
|
||||
slim = false,
|
||||
...props
|
||||
}: Props<T>) {
|
||||
return (
|
||||
<div className="form-group">
|
||||
<div className="col-sm-12">
|
||||
<div
|
||||
className={clsx('boxselector_wrapper', styles.root)}
|
||||
role="radiogroup"
|
||||
>
|
||||
<div className={styles.root} role="radiogroup">
|
||||
{options
|
||||
.filter((option) => !option.hide)
|
||||
.map((option) => (
|
||||
|
@ -32,14 +41,41 @@ export function BoxSelector<T extends number | string>({
|
|||
key={option.id}
|
||||
radioName={radioName}
|
||||
option={option}
|
||||
onChange={onChange}
|
||||
selectedValue={value}
|
||||
onSelect={handleSelect}
|
||||
disabled={option.disabled && option.disabled()}
|
||||
tooltip={option.tooltip && option.tooltip()}
|
||||
type={props.isMulti ? 'checkbox' : 'radio'}
|
||||
isSelected={isSelected}
|
||||
slim={slim}
|
||||
checkIcon={props.isMulti ? Minus : Check}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
function handleSelect(optionValue: T, limitedToBE: boolean) {
|
||||
if (props.isMulti) {
|
||||
const newValue = isSelected(optionValue)
|
||||
? props.value.filter((v) => v !== optionValue)
|
||||
: [...props.value, optionValue];
|
||||
props.onChange(newValue, limitedToBE);
|
||||
return;
|
||||
}
|
||||
|
||||
if (isSelected(optionValue)) {
|
||||
return;
|
||||
}
|
||||
|
||||
props.onChange(optionValue, limitedToBE);
|
||||
}
|
||||
|
||||
function isSelected(optionValue: T) {
|
||||
if (props.isMulti) {
|
||||
return props.value.includes(optionValue);
|
||||
}
|
||||
|
||||
return props.value === optionValue;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,129 +0,0 @@
|
|||
.boxselector_wrapper > div,
|
||||
.box-selector-item {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.boxselector_wrapper .boxselector_header,
|
||||
.box-selector-item .boxselector_header {
|
||||
font-size: 18px;
|
||||
margin-right: 20px;
|
||||
margin-bottom: 5px;
|
||||
font-weight: bold;
|
||||
user-select: none;
|
||||
color: var(--text-boxselector-header);
|
||||
}
|
||||
|
||||
.boxselector_wrapper input[type='radio'],
|
||||
.box-selector-item input[type='radio'] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.boxselector_wrapper label,
|
||||
.box-selector-item label {
|
||||
@apply border border-solid;
|
||||
@apply bg-gray-2 border-gray-5 text-black;
|
||||
@apply th-dark:bg-gray-iron-10 th-dark:border-gray-neutral-8 th-dark:text-white;
|
||||
|
||||
font-weight: normal;
|
||||
font-size: 12px;
|
||||
display: block;
|
||||
border-radius: 8px;
|
||||
padding: 15px;
|
||||
text-align: left;
|
||||
box-shadow: var(--shadow-boxselector-color);
|
||||
position: relative;
|
||||
|
||||
text-align: left;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
/* not disabled */
|
||||
.boxselector_wrapper input[type='radio']:not(:disabled) ~ label,
|
||||
.box-selector-item input[type='radio']:not(:disabled) ~ label {
|
||||
background-color: var(--bg-boxselector-color);
|
||||
|
||||
box-shadow: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* disabled */
|
||||
.box-selector-item input:disabled + label,
|
||||
.boxselector_wrapper label.boxselector_disabled {
|
||||
@apply !bg-white;
|
||||
@apply th-dark:!bg-gray-7;
|
||||
@apply th-highcontrast:!bg-black;
|
||||
filter: opacity(0.3) grayscale(1);
|
||||
|
||||
cursor: not-allowed;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.boxselector_wrapper label.boxselector_disabled a {
|
||||
cursor: pointer;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
/* checked */
|
||||
.boxselector_wrapper input[type='radio']:checked + label,
|
||||
.box-selector-item input[type='radio']:checked + label {
|
||||
@apply bg-blue-2 border-blue-6;
|
||||
@apply th-dark:bg-blue-10 th-dark:border-blue-7;
|
||||
@apply th-highcontrast:bg-blue-10 th-highcontrast:border-blue-7;
|
||||
|
||||
background-image: url(../../../assets/ico/checked.svg);
|
||||
background-repeat: no-repeat;
|
||||
background-position: right 15px top 15px;
|
||||
|
||||
border-radius: 8px;
|
||||
padding: 15px;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
@media only screen and (max-width: 700px) {
|
||||
.boxselector_wrapper {
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
|
||||
.box-selector-item.limited.business label,
|
||||
.box-selector-item.limited.business input[type='radio'] + label {
|
||||
@apply border-warning-7 bg-warning-1 text-black;
|
||||
@apply th-dark:bg-warning-8 th-dark:bg-opacity-10;
|
||||
@apply th-highcontrast:bg-warning-8 th-highcontrast:bg-opacity-10;
|
||||
}
|
||||
|
||||
.boxselector_img_container {
|
||||
width: 100%;
|
||||
margin-bottom: 20px;
|
||||
text-align: left;
|
||||
|
||||
line-height: 90px;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.boxselector_icon,
|
||||
.boxselector_icon img {
|
||||
font-size: 90px;
|
||||
}
|
||||
|
||||
.boxselector_icon > svg {
|
||||
margin-left: -5px;
|
||||
}
|
||||
|
||||
.boxselector_header pr-icon {
|
||||
margin-right: 5px;
|
||||
}
|
||||
|
||||
.boxselector_content {
|
||||
padding-left: 20px;
|
||||
}
|
||||
|
||||
.boxselector_img_container {
|
||||
line-height: 90px;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.box-selector-item p {
|
||||
margin-bottom: 0;
|
||||
color: var(--text-boxselector-header);
|
||||
}
|
|
@ -0,0 +1,50 @@
|
|||
.box-selector-item {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.box-selector-item .header {
|
||||
@apply text-black;
|
||||
@apply th-dark:text-white;
|
||||
@apply th-highcontrast:text-white;
|
||||
|
||||
font-size: 18px;
|
||||
font-weight: bold;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.image-container {
|
||||
margin-bottom: 20px;
|
||||
text-align: left;
|
||||
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.icon,
|
||||
.icon img {
|
||||
font-size: 90px;
|
||||
}
|
||||
|
||||
.slim .icon {
|
||||
font-size: 56px;
|
||||
}
|
||||
|
||||
.icon > svg {
|
||||
margin-left: -5px;
|
||||
}
|
||||
|
||||
.header pr-icon {
|
||||
margin-right: 5px;
|
||||
}
|
||||
|
||||
.content {
|
||||
padding-left: 20px;
|
||||
}
|
||||
|
||||
.box-selector-item.limited.business label,
|
||||
.box-selector-item.limited.business input:checked + label {
|
||||
@apply border-warning-7 bg-warning-1 text-black;
|
||||
@apply th-dark:bg-warning-8 th-dark:bg-opacity-10;
|
||||
@apply th-highcontrast:bg-warning-8 th-highcontrast:bg-opacity-10;
|
||||
|
||||
filter: none;
|
||||
}
|
|
@ -1,8 +1,10 @@
|
|||
import { Meta } from '@storybook/react';
|
||||
import { User } from 'lucide-react';
|
||||
import { ReactNode } from 'react';
|
||||
import { Briefcase } from 'lucide-react';
|
||||
|
||||
import { init as initFeatureService } from '@/react/portainer/feature-flags/feature-flags.service';
|
||||
import { Edition, FeatureId } from '@/react/portainer/feature-flags/enums';
|
||||
import Docker from '@/assets/ico/vendor/docker.svg?c';
|
||||
|
||||
import { IconProps } from '@@/Icon';
|
||||
|
||||
|
@ -14,7 +16,7 @@ const meta: Meta = {
|
|||
args: {
|
||||
selected: false,
|
||||
description: 'description',
|
||||
icon: User,
|
||||
icon: Briefcase,
|
||||
label: 'label',
|
||||
},
|
||||
};
|
||||
|
@ -30,7 +32,7 @@ interface ExampleProps {
|
|||
}
|
||||
|
||||
function Template({
|
||||
selected,
|
||||
selected = false,
|
||||
description = 'description',
|
||||
icon,
|
||||
label = 'label',
|
||||
|
@ -48,10 +50,10 @@ function Template({
|
|||
return (
|
||||
<div className="boxselector_wrapper">
|
||||
<BoxSelectorItem
|
||||
onChange={() => {}}
|
||||
onSelect={() => {}}
|
||||
option={option}
|
||||
radioName="radio"
|
||||
selectedValue={selected ? option.value : 0}
|
||||
isSelected={() => selected}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
@ -78,3 +80,36 @@ export function SelectedLimitedFeatureItem() {
|
|||
|
||||
return <Template feature={FeatureId.ACTIVITY_AUDIT} selected />;
|
||||
}
|
||||
|
||||
function IconTemplate({
|
||||
icon,
|
||||
iconType,
|
||||
}: {
|
||||
icon: ReactNode;
|
||||
iconType: 'raw' | 'logo' | 'badge';
|
||||
}) {
|
||||
return (
|
||||
<BoxSelectorItem
|
||||
onSelect={() => {}}
|
||||
option={{
|
||||
description: 'description',
|
||||
icon,
|
||||
iconType,
|
||||
label: 'label',
|
||||
id: 'id',
|
||||
value: 'value',
|
||||
}}
|
||||
isSelected={() => false}
|
||||
radioName="radio"
|
||||
slim
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function LogoItem() {
|
||||
return <IconTemplate icon={Docker} iconType="logo" />;
|
||||
}
|
||||
|
||||
export function BadgeItem() {
|
||||
return <IconTemplate icon={Briefcase} iconType="badge" />;
|
||||
}
|
||||
|
|
|
@ -1,46 +1,57 @@
|
|||
import clsx from 'clsx';
|
||||
import { Icon as ReactFeatherComponentType, Check } from 'lucide-react';
|
||||
|
||||
import { isLimitedToBE } from '@/react/portainer/feature-flags/feature-flags.service';
|
||||
import { Icon } from '@/react/components/Icon';
|
||||
|
||||
import './BoxSelectorItem.css';
|
||||
import { BadgeIcon } from '@@/BadgeIcon';
|
||||
|
||||
import { BoxSelectorOption } from './types';
|
||||
import styles from './BoxSelectorItem.module.css';
|
||||
import { BoxSelectorOption, Value } from './types';
|
||||
import { LimitedToBeIndicator } from './LimitedToBeIndicator';
|
||||
import { BoxOption } from './BoxOption';
|
||||
import { LogoIcon } from './LogoIcon';
|
||||
|
||||
interface Props<T extends number | string> {
|
||||
radioName: string;
|
||||
type Props<T extends Value> = {
|
||||
option: BoxSelectorOption<T>;
|
||||
onChange(value: T, limitedToBE: boolean): void;
|
||||
selectedValue: T;
|
||||
radioName: string;
|
||||
disabled?: boolean;
|
||||
tooltip?: string;
|
||||
}
|
||||
onSelect(value: T, limitedToBE: boolean): void;
|
||||
isSelected(value: T): boolean;
|
||||
type?: 'radio' | 'checkbox';
|
||||
slim?: boolean;
|
||||
checkIcon?: ReactFeatherComponentType;
|
||||
};
|
||||
|
||||
export function BoxSelectorItem<T extends number | string>({
|
||||
export function BoxSelectorItem<T extends Value>({
|
||||
radioName,
|
||||
option,
|
||||
onChange,
|
||||
selectedValue,
|
||||
onSelect = () => {},
|
||||
disabled,
|
||||
tooltip,
|
||||
type = 'radio',
|
||||
isSelected,
|
||||
slim = false,
|
||||
checkIcon = Check,
|
||||
}: Props<T>) {
|
||||
const limitedToBE = isLimitedToBE(option.feature);
|
||||
|
||||
const beIndicatorTooltipId = `box-selector-item-${radioName}-${option.id}-limited`;
|
||||
return (
|
||||
<BoxOption
|
||||
className={clsx({
|
||||
business: limitedToBE,
|
||||
limited: limitedToBE,
|
||||
className={clsx(styles.boxSelectorItem, {
|
||||
[styles.business]: limitedToBE,
|
||||
[styles.limited]: limitedToBE,
|
||||
})}
|
||||
radioName={radioName}
|
||||
option={option}
|
||||
selectedValue={selectedValue}
|
||||
disabled={disabled}
|
||||
onChange={(value) => onChange(value, limitedToBE)}
|
||||
isSelected={isSelected}
|
||||
disabled={isDisabled()}
|
||||
onSelect={(value) => onSelect(value, limitedToBE)}
|
||||
tooltip={tooltip}
|
||||
type={type}
|
||||
checkIcon={checkIcon}
|
||||
>
|
||||
<>
|
||||
{limitedToBE && (
|
||||
|
@ -49,19 +60,51 @@ export function BoxSelectorItem<T extends number | string>({
|
|||
featureId={option.feature}
|
||||
/>
|
||||
)}
|
||||
<div className={clsx({ 'opacity-30': limitedToBE })}>
|
||||
<div className="boxselector_img_container">
|
||||
{!!option.icon && (
|
||||
<Icon
|
||||
icon={option.icon}
|
||||
className="boxselector_icon !flex items-center"
|
||||
/>
|
||||
)}
|
||||
<div
|
||||
className={clsx('flex gap-2', {
|
||||
'opacity-30': limitedToBE,
|
||||
'flex-col justify-between h-full': !slim,
|
||||
'items-center slim': slim,
|
||||
})}
|
||||
>
|
||||
<div
|
||||
className={clsx(styles.imageContainer, 'flex items-center', {
|
||||
'flex-1': !slim,
|
||||
})}
|
||||
>
|
||||
{renderIcon()}
|
||||
</div>
|
||||
<div>
|
||||
<div className={styles.header}>{option.label}</div>
|
||||
<p>{option.description}</p>
|
||||
</div>
|
||||
<div className="boxselector_header">{option.label}</div>
|
||||
<p className="box-selector-item-description">{option.description}</p>
|
||||
</div>
|
||||
</>
|
||||
</BoxOption>
|
||||
);
|
||||
|
||||
function isDisabled() {
|
||||
return disabled || (limitedToBE && option.disabledWhenLimited);
|
||||
}
|
||||
|
||||
function renderIcon() {
|
||||
if (!option.icon) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (option.iconType === 'badge') {
|
||||
return <BadgeIcon icon={option.icon} />;
|
||||
}
|
||||
|
||||
if (option.iconType === 'logo') {
|
||||
return <LogoIcon icon={option.icon} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<Icon
|
||||
icon={option.icon}
|
||||
className={clsx(styles.icon, '!flex items-center')}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,44 +1,58 @@
|
|||
import { Edit, FileText, Globe, Upload } from 'lucide-react';
|
||||
import { Edit, FileText, Globe, UploadCloud } from 'lucide-react';
|
||||
|
||||
import GitIcon from '@/assets/ico/git.svg?c';
|
||||
|
||||
import { BadgeIcon } from '@@/BadgeIcon';
|
||||
|
||||
import { BoxSelectorOption } from '../types';
|
||||
|
||||
export const editor: BoxSelectorOption<'editor'> = {
|
||||
id: 'method_editor',
|
||||
icon: <BadgeIcon icon={Edit} />,
|
||||
icon: Edit,
|
||||
iconType: 'badge',
|
||||
label: 'Web editor',
|
||||
description: 'Use our Web editor',
|
||||
value: 'editor',
|
||||
};
|
||||
|
||||
export const upload: BoxSelectorOption<'upload'> = {
|
||||
id: 'method_upload',
|
||||
icon: <BadgeIcon icon={Upload} />,
|
||||
icon: UploadCloud,
|
||||
iconType: 'badge',
|
||||
label: 'Upload',
|
||||
description: 'Upload from your computer',
|
||||
value: 'upload',
|
||||
};
|
||||
|
||||
export const git: BoxSelectorOption<'repository'> = {
|
||||
id: 'method_repository',
|
||||
icon: <GitIcon />,
|
||||
icon: GitIcon,
|
||||
iconType: 'logo',
|
||||
label: 'Repository',
|
||||
description: 'Use a git repository',
|
||||
value: 'repository',
|
||||
};
|
||||
|
||||
export const template: BoxSelectorOption<'template'> = {
|
||||
export const edgeStackTemplate: BoxSelectorOption<'template'> = {
|
||||
id: 'method_template',
|
||||
icon: <BadgeIcon icon={FileText} />,
|
||||
icon: FileText,
|
||||
iconType: 'badge',
|
||||
label: 'Template',
|
||||
description: 'Use an Edge stack template',
|
||||
value: 'template',
|
||||
};
|
||||
|
||||
export const customTemplate: BoxSelectorOption<'template'> = {
|
||||
id: 'method_template',
|
||||
icon: FileText,
|
||||
iconType: 'badge',
|
||||
label: 'Custom template',
|
||||
description: 'Use a custom template',
|
||||
value: 'template',
|
||||
};
|
||||
|
||||
export const url: BoxSelectorOption<'url'> = {
|
||||
id: 'method_url',
|
||||
icon: <BadgeIcon icon={Globe} />,
|
||||
icon: Globe,
|
||||
iconType: 'badge',
|
||||
label: 'URL',
|
||||
description: 'Specify a URL to a file',
|
||||
value: 'url',
|
||||
|
|
|
@ -1,14 +1,20 @@
|
|||
import { ReactNode } from 'react';
|
||||
|
||||
import type { FeatureId } from '@/react/portainer/feature-flags/enums';
|
||||
|
||||
import { IconProps } from '@@/Icon';
|
||||
|
||||
export interface BoxSelectorOption<T> extends IconProps {
|
||||
id: string;
|
||||
label: string;
|
||||
description: string;
|
||||
value: T;
|
||||
disabled?: () => boolean;
|
||||
tooltip?: () => string;
|
||||
feature?: FeatureId;
|
||||
hide?: boolean;
|
||||
export type Value = number | string | boolean;
|
||||
|
||||
export interface BoxSelectorOption<T extends Value> extends IconProps {
|
||||
readonly id: string;
|
||||
readonly label: string;
|
||||
readonly description?: ReactNode;
|
||||
readonly value: T;
|
||||
readonly disabled?: () => boolean;
|
||||
readonly tooltip?: () => string;
|
||||
readonly feature?: FeatureId;
|
||||
readonly disabledWhenLimited?: boolean;
|
||||
readonly hide?: boolean;
|
||||
readonly iconType?: 'raw' | 'badge' | 'logo';
|
||||
}
|
||||
|
|
|
@ -16,7 +16,7 @@ interface Args {
|
|||
|
||||
function Template({ totalSteps = 5 }: Args) {
|
||||
const steps: Step[] = Array.from({ length: totalSteps }).map((_, index) => ({
|
||||
title: `step ${index + 1}`,
|
||||
label: `step ${index + 1}`,
|
||||
}));
|
||||
|
||||
const [currentStep, setCurrentStep] = useState(1);
|
||||
|
|
|
@ -3,7 +3,7 @@ import clsx from 'clsx';
|
|||
import styles from './Stepper.module.css';
|
||||
|
||||
export interface Step {
|
||||
title: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
|
@ -16,7 +16,7 @@ export function Stepper({ currentStep, steps }: Props) {
|
|||
<div className={styles.stepperWrapper}>
|
||||
{steps.map((step, index) => (
|
||||
<div
|
||||
key={step.title}
|
||||
key={step.label}
|
||||
className={clsx(styles.stepWrapper, {
|
||||
[styles.active]: index + 1 === currentStep,
|
||||
[styles.completed]: index + 1 < currentStep,
|
||||
|
@ -24,7 +24,7 @@ export function Stepper({ currentStep, steps }: Props) {
|
|||
>
|
||||
<div className={styles.step}>
|
||||
<div className={styles.stepCounter}>{index + 1}</div>
|
||||
<div className={styles.stepName}>{step.title}</div>
|
||||
<div className={styles.stepName}>{step.label}</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
|
|
@ -6,7 +6,6 @@ import highcontrastmode from '@/assets/ico/theme/highcontrastmode.svg?c';
|
|||
// general icons
|
||||
import heartbeatup from '@/assets/ico/heartbeat-up.svg?c';
|
||||
import heartbeatdown from '@/assets/ico/heartbeat-down.svg?c';
|
||||
import checked from '@/assets/ico/checked.svg?c';
|
||||
import dataflow from '@/assets/ico/dataflow-1.svg?c';
|
||||
import git from '@/assets/ico/git.svg?c';
|
||||
import kube from '@/assets/ico/kube.svg?c';
|
||||
|
@ -53,7 +52,6 @@ export const SvgIcons = {
|
|||
lightmode,
|
||||
highcontrastmode,
|
||||
dataflow,
|
||||
checked,
|
||||
dockericon,
|
||||
git,
|
||||
laptopcode,
|
||||
|
|
|
@ -0,0 +1,70 @@
|
|||
import { Box, Boxes } from 'lucide-react';
|
||||
|
||||
import { KubernetesApplicationDataAccessPolicies } from '@/kubernetes/models/application/models';
|
||||
|
||||
import { BoxSelector, BoxSelectorOption } from '@@/BoxSelector';
|
||||
|
||||
interface Props {
|
||||
isEdit: boolean;
|
||||
persistedFoldersUseExistingVolumes: boolean;
|
||||
value: number;
|
||||
onChange(value: number): void;
|
||||
}
|
||||
|
||||
export function KubeApplicationAccessPolicySelector({
|
||||
isEdit,
|
||||
persistedFoldersUseExistingVolumes,
|
||||
value,
|
||||
onChange,
|
||||
}: Props) {
|
||||
const options = getOptions(value, isEdit, persistedFoldersUseExistingVolumes);
|
||||
|
||||
return (
|
||||
<BoxSelector
|
||||
slim
|
||||
options={options}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
radioName="data_access_policy"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function getOptions(
|
||||
value: number,
|
||||
isEdit: boolean,
|
||||
persistedFoldersUseExistingVolumes: boolean
|
||||
): ReadonlyArray<BoxSelectorOption<number>> {
|
||||
return [
|
||||
{
|
||||
value: KubernetesApplicationDataAccessPolicies.ISOLATED,
|
||||
id: 'data_access_isolated',
|
||||
icon: Boxes,
|
||||
iconType: 'badge',
|
||||
label: 'Isolated',
|
||||
description:
|
||||
'Application will be deployed as a StatefulSet with each instantiating their own data',
|
||||
tooltip: () =>
|
||||
isEdit || persistedFoldersUseExistingVolumes
|
||||
? 'Changing the data access policy is not allowed'
|
||||
: '',
|
||||
disabled: () =>
|
||||
(isEdit &&
|
||||
value !== KubernetesApplicationDataAccessPolicies.ISOLATED) ||
|
||||
persistedFoldersUseExistingVolumes,
|
||||
},
|
||||
{
|
||||
value: KubernetesApplicationDataAccessPolicies.SHARED,
|
||||
id: 'data_access_shared',
|
||||
icon: Box,
|
||||
iconType: 'badge',
|
||||
label: 'Shared',
|
||||
description:
|
||||
'Application will be deployed as a Deployment with a shared storage access',
|
||||
tooltip: () =>
|
||||
isEdit ? 'Changing the data access policy is not allowed' : '',
|
||||
disabled: () =>
|
||||
isEdit && value !== KubernetesApplicationDataAccessPolicies.SHARED,
|
||||
},
|
||||
] as const;
|
||||
}
|
|
@ -0,0 +1,27 @@
|
|||
import { BoxSelector } from '@@/BoxSelector';
|
||||
|
||||
import { getDeploymentOptions } from './deploymentOptions';
|
||||
|
||||
interface Props {
|
||||
value: number;
|
||||
onChange(value: number): void;
|
||||
supportGlobalDeployment: boolean;
|
||||
}
|
||||
|
||||
export function KubeApplicationDeploymentTypeSelector({
|
||||
supportGlobalDeployment,
|
||||
value,
|
||||
onChange,
|
||||
}: Props) {
|
||||
const options = getDeploymentOptions(supportGlobalDeployment);
|
||||
|
||||
return (
|
||||
<BoxSelector
|
||||
slim
|
||||
options={options}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
radioName="deploymentType"
|
||||
/>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,34 @@
|
|||
import { Boxes, Sliders } from 'lucide-react';
|
||||
|
||||
import { KubernetesApplicationDeploymentTypes } from '@/kubernetes/models/application/models';
|
||||
|
||||
import { BoxSelectorOption } from '@@/BoxSelector';
|
||||
|
||||
export function getDeploymentOptions(
|
||||
supportGlobalDeployment: boolean
|
||||
): ReadonlyArray<BoxSelectorOption<number>> {
|
||||
return [
|
||||
{
|
||||
id: 'deployment_replicated',
|
||||
label: 'Replicated',
|
||||
value: KubernetesApplicationDeploymentTypes.REPLICATED,
|
||||
icon: Sliders,
|
||||
iconType: 'badge',
|
||||
description: 'Run one or multiple instances of this container',
|
||||
},
|
||||
{
|
||||
id: 'deployment_global',
|
||||
disabled: () => !supportGlobalDeployment,
|
||||
tooltip: () =>
|
||||
!supportGlobalDeployment
|
||||
? 'The storage or access policy used for persisted folders cannot be used with this option'
|
||||
: '',
|
||||
label: 'Global',
|
||||
description:
|
||||
'Application will be deployed as a DaemonSet with an instance on each node of the cluster',
|
||||
value: KubernetesApplicationDeploymentTypes.GLOBAL,
|
||||
icon: Boxes,
|
||||
iconType: 'badge',
|
||||
},
|
||||
] as const;
|
||||
}
|
|
@ -0,0 +1,34 @@
|
|||
import { BoxSelector } from '@@/BoxSelector';
|
||||
|
||||
import { Team } from '../../users/teams/types';
|
||||
import { ResourceControlOwnership } from '../types';
|
||||
|
||||
import { useOptions } from './useOptions';
|
||||
|
||||
export function AccessTypeSelector({
|
||||
name,
|
||||
isAdmin,
|
||||
isPublicVisible,
|
||||
teams,
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
name: string;
|
||||
isAdmin: boolean;
|
||||
teams: Team[];
|
||||
isPublicVisible: boolean;
|
||||
value: ResourceControlOwnership;
|
||||
onChange(value: ResourceControlOwnership): void;
|
||||
}) {
|
||||
const options = useOptions(isAdmin, teams, isPublicVisible);
|
||||
|
||||
return (
|
||||
<BoxSelector
|
||||
slim
|
||||
radioName={name}
|
||||
value={value}
|
||||
options={options}
|
||||
onChange={onChange}
|
||||
/>
|
||||
);
|
||||
}
|
|
@ -4,7 +4,6 @@ import { FormikErrors } from 'formik';
|
|||
import { useUser } from '@/react/hooks/useUser';
|
||||
import { EnvironmentId } from '@/react/portainer/environments/types';
|
||||
|
||||
import { BoxSelector } from '@@/BoxSelector';
|
||||
import { FormError } from '@@/form-components/FormError';
|
||||
|
||||
import { ResourceControlOwnership, AccessControlFormData } from '../types';
|
||||
|
@ -12,7 +11,7 @@ import { ResourceControlOwnership, AccessControlFormData } from '../types';
|
|||
import { UsersField } from './UsersField';
|
||||
import { TeamsField } from './TeamsField';
|
||||
import { useLoadState } from './useLoadState';
|
||||
import { useOptions } from './useOptions';
|
||||
import { AccessTypeSelector } from './AccessTypeSelector';
|
||||
|
||||
interface Props {
|
||||
values: AccessControlFormData;
|
||||
|
@ -34,7 +33,6 @@ export function EditDetails({
|
|||
const { user, isAdmin } = useUser();
|
||||
|
||||
const { users, teams, isLoading } = useLoadState(environmentId);
|
||||
const options = useOptions(isAdmin, teams, isPublicVisible);
|
||||
|
||||
const handleChange = useCallback(
|
||||
(partialValues: Partial<typeof values>) => {
|
||||
|
@ -50,11 +48,13 @@ export function EditDetails({
|
|||
|
||||
return (
|
||||
<>
|
||||
<BoxSelector
|
||||
radioName={withNamespace('ownership')}
|
||||
<AccessTypeSelector
|
||||
onChange={handleChangeOwnership}
|
||||
name={withNamespace('ownership')}
|
||||
value={values.ownership}
|
||||
options={options}
|
||||
onChange={(ownership) => handleChangeOwnership(ownership)}
|
||||
isAdmin={isAdmin}
|
||||
isPublicVisible={isPublicVisible}
|
||||
teams={teams}
|
||||
/>
|
||||
|
||||
{values.ownership === ResourceControlOwnership.RESTRICTED && (
|
||||
|
|
|
@ -70,9 +70,17 @@ function nonAdminOptions(teams?: Team[]) {
|
|||
'access_restricted',
|
||||
<BadgeIcon icon={ownershipIcon('restricted')} />,
|
||||
'Restricted',
|
||||
teams.length === 1
|
||||
? `I want any member of my team (${teams[0].Name}) to be able to manage this resource`
|
||||
: 'I want to restrict the management of this resource to one or more of my teams',
|
||||
teams.length === 1 ? (
|
||||
<>
|
||||
I want any member of my team (<b>{teams[0].Name}</b>) to be able to
|
||||
manage this resource
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
I want to restrict the management of this resource to one or more of
|
||||
my teams
|
||||
</>
|
||||
),
|
||||
ResourceControlOwnership.RESTRICTED
|
||||
),
|
||||
]);
|
||||
|
|
|
@ -66,7 +66,7 @@ export function EnvironmentTypeSelectView() {
|
|||
trackEvent('endpoint-wizard-endpoint-select', {
|
||||
category: 'portainer',
|
||||
metadata: {
|
||||
environment: steps.map((step) => step.title).join('/'),
|
||||
environment: steps.map((step) => step.label).join('/'),
|
||||
},
|
||||
});
|
||||
|
||||
|
|
|
@ -1,7 +1,6 @@
|
|||
import { BoxSelector } from '@@/BoxSelector';
|
||||
import { FormSection } from '@@/form-components/FormSection';
|
||||
|
||||
import { Option } from '../components/Option';
|
||||
|
||||
import { environmentTypes } from './environment-types';
|
||||
|
||||
export type EnvironmentSelectorValue = typeof environmentTypes[number]['id'];
|
||||
|
@ -23,40 +22,26 @@ export function EnvironmentSelector({
|
|||
onChange,
|
||||
createEdgeDevice,
|
||||
}: Props) {
|
||||
const options = filterEdgeDevicesIfNeed(environmentTypes, createEdgeDevice);
|
||||
|
||||
return (
|
||||
<div className="row">
|
||||
<div className="form-horizontal">
|
||||
<FormSection title="Select your environment(s)">
|
||||
<p className="text-muted small">
|
||||
You can onboard different types of environments, select all that
|
||||
apply.
|
||||
</p>
|
||||
<div className="flex gap-4 flex-wrap">
|
||||
{filterEdgeDevicesIfNeed(environmentTypes, createEdgeDevice).map(
|
||||
(eType) => (
|
||||
<Option
|
||||
key={eType.id}
|
||||
featureId={eType.featureId}
|
||||
title={eType.title}
|
||||
description={eType.description}
|
||||
icon={eType.icon}
|
||||
active={value.includes(eType.id)}
|
||||
onClick={() => handleClick(eType.id)}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
|
||||
<BoxSelector
|
||||
options={options}
|
||||
isMulti
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
radioName="type-selector"
|
||||
/>
|
||||
</FormSection>
|
||||
</div>
|
||||
);
|
||||
|
||||
function handleClick(eType: EnvironmentSelectorValue) {
|
||||
if (value.includes(eType)) {
|
||||
onChange(value.filter((v) => v !== eType));
|
||||
return;
|
||||
}
|
||||
|
||||
onChange([...value, eType]);
|
||||
}
|
||||
}
|
||||
|
||||
function filterEdgeDevicesIfNeed(
|
||||
|
@ -64,8 +49,8 @@ function filterEdgeDevicesIfNeed(
|
|||
createEdgeDevice?: boolean
|
||||
) {
|
||||
if (!createEdgeDevice) {
|
||||
return types;
|
||||
return [...types];
|
||||
}
|
||||
|
||||
return types.filter((eType) => hasEdge.includes(eType.id));
|
||||
return [...types.filter((eType) => hasEdge.includes(eType.id))];
|
||||
}
|
||||
|
|
|
@ -1,52 +1,71 @@
|
|||
import { FeatureId } from '@/react/portainer/feature-flags/enums';
|
||||
import DockerIcon from '@/assets/ico/vendor/docker-icon.svg?c';
|
||||
import Kube from '@/assets/ico/kube.svg?c';
|
||||
import MicrosoftIcon from '@/assets/ico/vendor/microsoft-icon.svg?c';
|
||||
import NomadIcon from '@/assets/ico/vendor/nomad-icon.svg?c';
|
||||
import Docker from '@/assets/ico/vendor/docker.svg?c';
|
||||
import Kubernetes from '@/assets/ico/vendor/kubernetes.svg?c';
|
||||
import Azure from '@/assets/ico/vendor/azure.svg?c';
|
||||
import Nomad from '@/assets/ico/vendor/nomad.svg?c';
|
||||
|
||||
import KaaSIcon from './kaas-icon.svg?c';
|
||||
|
||||
export const environmentTypes = [
|
||||
{
|
||||
id: 'dockerStandalone',
|
||||
title: 'Docker Standalone',
|
||||
icon: DockerIcon,
|
||||
value: 'dockerStandalone',
|
||||
label: 'Docker Standalone',
|
||||
icon: Docker,
|
||||
iconType: 'logo',
|
||||
description: 'Connect to Docker Standalone via URL/IP, API or Socket',
|
||||
featureId: undefined,
|
||||
},
|
||||
{
|
||||
id: 'dockerSwarm',
|
||||
title: 'Docker Swarm',
|
||||
icon: DockerIcon,
|
||||
value: 'dockerSwarm',
|
||||
label: 'Docker Swarm',
|
||||
icon: Docker,
|
||||
iconType: 'logo',
|
||||
description: 'Connect to Docker Swarm via URL/IP, API or Socket',
|
||||
featureId: undefined,
|
||||
},
|
||||
{
|
||||
id: 'kubernetes',
|
||||
title: 'Kubernetes',
|
||||
icon: Kube,
|
||||
value: 'kubernetes',
|
||||
label: 'Kubernetes',
|
||||
icon: Kubernetes,
|
||||
iconType: 'logo',
|
||||
description: 'Connect to a kubernetes environment via URL/IP',
|
||||
featureId: undefined,
|
||||
},
|
||||
{
|
||||
id: 'aci',
|
||||
title: 'ACI',
|
||||
value: 'aci',
|
||||
label: 'ACI',
|
||||
description: 'Connect to ACI environment via API',
|
||||
icon: MicrosoftIcon,
|
||||
featureId: undefined,
|
||||
iconType: 'logo',
|
||||
icon: Azure,
|
||||
},
|
||||
{
|
||||
id: 'nomad',
|
||||
title: 'Nomad',
|
||||
value: 'nomad',
|
||||
label: 'Nomad',
|
||||
description: 'Connect to HashiCorp Nomad environment via API',
|
||||
icon: NomadIcon,
|
||||
featureId: FeatureId.NOMAD,
|
||||
icon: Nomad,
|
||||
iconType: 'logo',
|
||||
feature: FeatureId.NOMAD,
|
||||
disabledWhenLimited: true,
|
||||
},
|
||||
{
|
||||
id: 'kaas',
|
||||
title: 'KaaS',
|
||||
value: 'kaas',
|
||||
label: 'KaaS',
|
||||
description: 'Provision a Kubernetes environment with a cloud provider',
|
||||
icon: KaaSIcon,
|
||||
featureId: FeatureId.KAAS_PROVISIONING,
|
||||
iconType: 'logo',
|
||||
feature: FeatureId.KAAS_PROVISIONING,
|
||||
disabledWhenLimited: true,
|
||||
},
|
||||
] as const;
|
||||
|
||||
export const formTitles = {
|
||||
dockerStandalone: 'Connect to your Docker Standalone environment',
|
||||
dockerSwarm: 'Connect to your Docker Swarm environment',
|
||||
kubernetes: 'Connect to your Kubernetes environment',
|
||||
aci: 'Connect to your ACI environment',
|
||||
nomad: 'Connect to your Nomad environment',
|
||||
kaas: 'Provision a KaaS environment',
|
||||
};
|
||||
|
|
|
@ -18,7 +18,10 @@ import { Button } from '@@/buttons';
|
|||
import { FormSection } from '@@/form-components/FormSection';
|
||||
import { Icon } from '@@/Icon';
|
||||
|
||||
import { environmentTypes } from '../EnvironmentTypeSelectView/environment-types';
|
||||
import {
|
||||
environmentTypes,
|
||||
formTitles,
|
||||
} from '../EnvironmentTypeSelectView/environment-types';
|
||||
import { EnvironmentSelectorValue } from '../EnvironmentTypeSelectView/EnvironmentSelector';
|
||||
|
||||
import { WizardDocker } from './WizardDocker';
|
||||
|
@ -77,10 +80,7 @@ export function EnvironmentCreationView() {
|
|||
<Stepper steps={steps} currentStep={currentStepIndex + 1} />
|
||||
|
||||
<div className="mt-12">
|
||||
<FormSection
|
||||
title={`Connect to your ${currentStep.title}
|
||||
environment`}
|
||||
>
|
||||
<FormSection title={formTitles[currentStep.id]}>
|
||||
<Component
|
||||
onCreate={handleCreateEnvironment}
|
||||
isDockerStandalone={isDockerStandalone}
|
||||
|
|
|
@ -1,13 +1,12 @@
|
|||
import { EnvironmentCreationTypes } from '@/react/portainer/environments/types';
|
||||
|
||||
import { BoxSelectorOption } from '@@/BoxSelector';
|
||||
import { Value, BoxSelectorOption } from '@@/BoxSelector/types';
|
||||
|
||||
import { useCreateEdgeDeviceParam } from '../hooks/useCreateEdgeDeviceParam';
|
||||
|
||||
export function useFilterEdgeOptionsIfNeeded<T = EnvironmentCreationTypes>(
|
||||
options: BoxSelectorOption<T>[],
|
||||
edgeValue: T
|
||||
) {
|
||||
export function useFilterEdgeOptionsIfNeeded<
|
||||
T extends Value = EnvironmentCreationTypes
|
||||
>(options: BoxSelectorOption<T>[], edgeValue: T) {
|
||||
const createEdgeDevice = useCreateEdgeDeviceParam();
|
||||
|
||||
if (!createEdgeDevice) {
|
||||
|
|
|
@ -20,6 +20,7 @@ export enum FeatureId {
|
|||
REGISTRY_MANAGEMENT = 'registry-management',
|
||||
K8S_SETUP_DEFAULT = 'k8s-setup-default',
|
||||
S3_BACKUP_SETTING = 's3-backup-setting',
|
||||
S3_RESTORE = 'restore-s3-form',
|
||||
HIDE_INTERNAL_AUTHENTICATION_PROMPT = 'hide-internal-authentication-prompt',
|
||||
TEAM_MEMBERSHIP = 'team-membership',
|
||||
HIDE_INTERNAL_AUTH = 'hide-internal-auth',
|
||||
|
|
|
@ -28,6 +28,7 @@ export async function init(edition: Edition) {
|
|||
[FeatureId.RBAC_ROLES]: Edition.BE,
|
||||
[FeatureId.REGISTRY_MANAGEMENT]: Edition.BE,
|
||||
[FeatureId.S3_BACKUP_SETTING]: Edition.BE,
|
||||
[FeatureId.S3_RESTORE]: Edition.BE,
|
||||
[FeatureId.TEAM_MEMBERSHIP]: Edition.BE,
|
||||
[FeatureId.FORCE_REDEPLOYMENT]: Edition.BE,
|
||||
[FeatureId.HIDE_AUTO_UPDATE_WINDOW]: Edition.BE,
|
||||
|
|
Loading…
Reference in New Issue