diff --git a/.prettierrc b/.prettierrc index 86d3c52f2..184ed56cb 100644 --- a/.prettierrc +++ b/.prettierrc @@ -13,10 +13,11 @@ }, { "files": [ - "*.{j,t}sx" + "*.{j,t}sx", + "*.ts" ], "options": { - "printWidth": 80, + "printWidth": 80 } } ] diff --git a/.storybook/preview.js b/.storybook/preview.js index 9a31caa06..127a08b28 100644 --- a/.storybook/preview.js +++ b/.storybook/preview.js @@ -1,5 +1,7 @@ import '../app/assets/css'; +import { pushStateLocationPlugin, UIRouter } from '@uirouter/react'; + export const parameters = { actions: { argTypesRegex: '^on[A-Z].*' }, controls: { @@ -9,3 +11,11 @@ export const parameters = { }, }, }; + +export const decorators = [ + (Story) => ( + + + + ), +]; diff --git a/app/assets/css/app.css b/app/assets/css/app.css index 3a3a9c16a..e3e3d5480 100644 --- a/app/assets/css/app.css +++ b/app/assets/css/app.css @@ -596,10 +596,6 @@ a[ng-click] { background-color: var(--bg-log-line-selected-color); } -.row.header .meta .page { - padding-top: 7px; -} - .tag:not(.token) { padding: 2px 6px; color: white; @@ -739,19 +735,6 @@ a[ng-click] { } /*!toaster override*/ -/*angular-loading-bar override*/ -#loadingbar-placeholder { - margin-bottom: 0; - height: 3px; -} - -#loading-bar .bar { - position: relative; - height: 3px; - background: var(--blue-3); -} -/*!angular-loading-bar override*/ - .monospaced { font-family: monospace; font-weight: 600; diff --git a/app/assets/css/rdash.css b/app/assets/css/rdash.css index e885c51f3..072f0880a 100644 --- a/app/assets/css/rdash.css +++ b/app/assets/css/rdash.css @@ -48,94 +48,6 @@ } } -/** - * Header - */ -.row.header { - height: 60px; - background: var(--bg-row-header-color); - margin-bottom: 15px; -} -.row.header > div:last-child { - padding-right: 0; -} -.row.header .meta .page { - font-size: 17px; - padding-top: 11px; -} -.row.header .meta .breadcrumb-links { - font-size: 10px; -} -.row.header .meta div { - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; -} -.row.header .login a { - padding: 18px; - display: block; -} -.row.header .user { - min-width: 130px; -} -.row.header .user > .item { - width: 65px; - height: 60px; - float: right; - display: inline-block; - text-align: center; - vertical-align: middle; -} -.row.header .user > .item a { - color: #919191; - display: block; -} -.row.header .user > .item i { - font-size: 20px; - line-height: 55px; -} -.row.header .user > .item img { - width: 40px; - height: 40px; - margin-top: 10px; - border-radius: 2px; -} -.row.header .user > .item ul.dropdown-menu { - border-radius: 2px; - -webkit-box-shadow: 0 6px 12px rgba(0, 0, 0, 0.05); - box-shadow: 0 6px 12px rgba(0, 0, 0, 0.05); -} -.row.header .user > .item ul.dropdown-menu .dropdown-header { - text-align: center; -} -.row.header .user > .item ul.dropdown-menu li.link { - text-align: left; -} -.row.header .user > .item ul.dropdown-menu li.link a { - padding-left: 7px; - padding-right: 7px; -} -.row.header .user > .item ul.dropdown-menu:before { - position: absolute; - top: -7px; - right: 23px; - display: inline-block; - border-right: 7px solid transparent; - border-bottom: 7px solid rgba(0, 0, 0, 0.2); - border-left: 7px solid transparent; - content: ''; -} -.row.header .user > .item ul.dropdown-menu:after { - position: absolute; - top: -6px; - right: 24px; - display: inline-block; - border-right: 6px solid transparent; - border-bottom: 6px solid #ffffff; - border-left: 6px solid transparent; - content: ''; -} - .loading { width: 40px; height: 40px; diff --git a/app/config.js b/app/config.js index 26f639522..0a7ef1125 100644 --- a/app/config.js +++ b/app/config.js @@ -1,6 +1,6 @@ -import toastr from 'toastr'; import { Terminal } from 'xterm'; import * as fit from 'xterm/lib/addons/fit/fit'; +import { agentInterceptor } from './portainer/services/axios'; /* @ngInject */ export function configApp($urlRouterProvider, $httpProvider, localStorageServiceProvider, jwtOptionsProvider, $uibTooltipProvider, $compileProvider, cfpLoadingBarProvider) { @@ -21,28 +21,9 @@ export function configApp($urlRouterProvider, $httpProvider, localStorageService $httpProvider.defaults.headers.put['Content-Type'] = 'application/json'; $httpProvider.defaults.headers.patch['Content-Type'] = 'application/json'; - $httpProvider.interceptors.push( - /* @ngInject */ function (HttpRequestHelper) { - return { - request(config) { - if (config.url.indexOf('/docker/') > -1) { - config.headers['X-PortainerAgent-Target'] = HttpRequestHelper.portainerAgentTargetHeader(); - if (HttpRequestHelper.portainerAgentManagerOperation()) { - config.headers['X-PortainerAgent-ManagerOperation'] = '1'; - } - } - return config; - }, - }; - } - ); - - toastr.options = { - timeOut: 3000, - closeButton: true, - progressBar: true, - tapToDismiss: false, - }; + $httpProvider.interceptors.push(() => ({ + request: agentInterceptor, + })); Terminal.applyAddon(fit); diff --git a/app/docker/views/docker-features-configuration/docker-features-configuration.controller.js b/app/docker/views/docker-features-configuration/docker-features-configuration.controller.js index 4570f8afc..4d6de8ede 100644 --- a/app/docker/views/docker-features-configuration/docker-features-configuration.controller.js +++ b/app/docker/views/docker-features-configuration/docker-features-configuration.controller.js @@ -1,14 +1,15 @@ -import { HIDE_AUTO_UPDATE_WINDOW } from 'Portainer/feature-flags/feature-ids'; +import { FeatureId } from '@/portainer/feature-flags/enums'; export default class DockerFeaturesConfigurationController { /* @ngInject */ - constructor($async, EndpointService, Notifications, StateManager) { + constructor($async, $scope, EndpointService, Notifications, StateManager) { this.$async = $async; + this.$scope = $scope; this.EndpointService = EndpointService; this.Notifications = Notifications; this.StateManager = StateManager; - this.limitedFeature = HIDE_AUTO_UPDATE_WINDOW; + this.limitedFeature = FeatureId.HIDE_AUTO_UPDATE_WINDOW; this.formValues = { enableHostManagementFeatures: false, @@ -26,9 +27,45 @@ export default class DockerFeaturesConfigurationController { this.state = { actionInProgress: false, + autoUpdateSettings: { Enabled: false }, + timeZone: '', }; this.save = this.save.bind(this); + this.onChangeField = this.onChangeField.bind(this); + this.onToggleAutoUpdate = this.onToggleAutoUpdate.bind(this); + this.onChangeEnableHostManagementFeatures = this.onChangeField('enableHostManagementFeatures'); + this.onChangeAllowVolumeBrowserForRegularUsers = this.onChangeField('allowVolumeBrowserForRegularUsers'); + this.onChangeDisableBindMountsForRegularUsers = this.onChangeField('disableBindMountsForRegularUsers'); + this.onChangeDisablePrivilegedModeForRegularUsers = this.onChangeField('disablePrivilegedModeForRegularUsers'); + this.onChangeDisableHostNamespaceForRegularUsers = this.onChangeField('disableHostNamespaceForRegularUsers'); + this.onChangeDisableStackManagementForRegularUsers = this.onChangeField('disableStackManagementForRegularUsers'); + this.onChangeDisableDeviceMappingForRegularUsers = this.onChangeField('disableDeviceMappingForRegularUsers'); + this.onChangeDisableContainerCapabilitiesForRegularUsers = this.onChangeField('disableContainerCapabilitiesForRegularUsers'); + this.onChangeDisableSysctlSettingForRegularUsers = this.onChangeField('disableSysctlSettingForRegularUsers'); + } + + onToggleAutoUpdate(value) { + return this.$scope.$evalAsync(() => { + this.state.autoUpdateSettings.Enabled = value; + }); + } + + onChange(values) { + return this.$scope.$evalAsync(() => { + this.formValues = { + ...this.formValues, + ...values, + }; + }); + } + + onChangeField(field) { + return (value) => { + this.onChange({ + [field]: value, + }); + }; } isContainerEditDisabled() { diff --git a/app/docker/views/docker-features-configuration/docker-features-configuration.html b/app/docker/views/docker-features-configuration/docker-features-configuration.html index 3f9675b6c..c08d902b8 100644 --- a/app/docker/views/docker-features-configuration/docker-features-configuration.html +++ b/app/docker/views/docker-features-configuration/docker-features-configuration.html @@ -20,24 +20,26 @@
@@ -49,12 +51,13 @@
@@ -67,73 +70,80 @@
diff --git a/app/edge/components/edge-stack-deployment-type-selector/edge-stack-deployment-type-selector.html b/app/edge/components/edge-stack-deployment-type-selector/edge-stack-deployment-type-selector.html index 4eae73cc5..2f53d2fcd 100644 --- a/app/edge/components/edge-stack-deployment-type-selector/edge-stack-deployment-type-selector.html +++ b/app/edge/components/edge-stack-deployment-type-selector/edge-stack-deployment-type-selector.html @@ -1,4 +1,4 @@
Deployment type
- + diff --git a/app/edge/views/edge-stacks/createEdgeStackView/docker-compose-form/docker-compose-form.controller.js b/app/edge/views/edge-stacks/createEdgeStackView/docker-compose-form/docker-compose-form.controller.js index c78caa495..25ee41886 100644 --- a/app/edge/views/edge-stacks/createEdgeStackView/docker-compose-form/docker-compose-form.controller.js +++ b/app/edge/views/edge-stacks/createEdgeStackView/docker-compose-form/docker-compose-form.controller.js @@ -23,7 +23,8 @@ class DockerComposeFormController { this.formValues = values; } - onChangeMethod() { + onChangeMethod(method) { + this.state.Method = method; this.formValues.StackFileContent = ''; this.selectedTemplate = null; } diff --git a/app/edge/views/edge-stacks/createEdgeStackView/docker-compose-form/docker-compose-form.html b/app/edge/views/edge-stacks/createEdgeStackView/docker-compose-form/docker-compose-form.html index 48388c9dc..4a61d2889 100644 --- a/app/edge/views/edge-stacks/createEdgeStackView/docker-compose-form/docker-compose-form.html +++ b/app/edge/views/edge-stacks/createEdgeStackView/docker-compose-form/docker-compose-form.html @@ -1,7 +1,7 @@
Build method
- + Build method
- + Build method - +
@@ -197,12 +198,12 @@
diff --git a/app/kubernetes/views/configure/configureController.js b/app/kubernetes/views/configure/configureController.js index 330cc9853..67cd69356 100644 --- a/app/kubernetes/views/configure/configureController.js +++ b/app/kubernetes/views/configure/configureController.js @@ -6,8 +6,8 @@ import { KubernetesIngressClass } from 'Kubernetes/ingress/models'; import KubernetesFormValidationHelper from 'Kubernetes/helpers/formValidationHelper'; import { KubernetesIngressClassTypes } from 'Kubernetes/ingress/constants'; import KubernetesNamespaceHelper from 'Kubernetes/helpers/namespaceHelper'; -import { K8S_SETUP_DEFAULT } from '@/portainer/feature-flags/feature-ids'; -import { HIDE_AUTO_UPDATE_WINDOW } from 'Portainer/feature-flags/feature-ids'; +import { FeatureId } from '@/portainer/feature-flags/enums'; + class KubernetesConfigureController { /* #region CONSTRUCTOR */ @@ -15,6 +15,7 @@ class KubernetesConfigureController { constructor( $async, $state, + $scope, Notifications, KubernetesStorageService, EndpointService, @@ -26,6 +27,7 @@ class KubernetesConfigureController { ) { this.$async = $async; this.$state = $state; + this.$scope = $scope; this.Notifications = Notifications; this.KubernetesStorageService = KubernetesStorageService; this.EndpointService = EndpointService; @@ -39,8 +41,10 @@ class KubernetesConfigureController { this.onInit = this.onInit.bind(this); this.configureAsync = this.configureAsync.bind(this); - this.limitedFeature = K8S_SETUP_DEFAULT; - this.limitedFeatureAutoWindow = HIDE_AUTO_UPDATE_WINDOW; + this.limitedFeature = FeatureId.K8S_SETUP_DEFAULT; + this.limitedFeatureAutoWindow = FeatureId.HIDE_AUTO_UPDATE_WINDOW; + this.onToggleAutoUpdate = this.onToggleAutoUpdate.bind(this); + this.onChangeEnableResourceOverCommit = this.onChangeEnableResourceOverCommit.bind(this); } /* #endregion */ @@ -103,6 +107,15 @@ class KubernetesConfigureController { } /* #endregion */ + onChangeEnableResourceOverCommit(enabled) { + this.$scope.$evalAsync(() => { + this.formValues.EnableResourceOverCommit = enabled; + if (enabled) { + this.formValues.ResourceOverCommitPercentage = 20; + } + }); + } + /* #region CONFIGURE */ assignFormValuesToEndpoint(endpoint, storageClasses, ingressClasses) { endpoint.Kubernetes.Configuration.StorageClasses = storageClasses; @@ -244,6 +257,12 @@ class KubernetesConfigureController { return this.formValues.RestrictDefaultNamespace && !this.oldFormValues.RestrictDefaultNamespace; } + onToggleAutoUpdate(value) { + return this.$scope.$evalAsync(() => { + this.state.autoUpdateSettings.Enabled = value; + }); + } + /* #region ON INIT */ async onInit() { this.state = { diff --git a/app/kubernetes/views/deploy/deploy.html b/app/kubernetes/views/deploy/deploy.html index 135a5be00..ccc4d56a9 100644 --- a/app/kubernetes/views/deploy/deploy.html +++ b/app/kubernetes/views/deploy/deploy.html @@ -39,18 +39,24 @@
Build method
- +
Deployment type
diff --git a/app/kubernetes/views/deploy/deployController.js b/app/kubernetes/views/deploy/deployController.js index 5d6f08e15..77610061a 100644 --- a/app/kubernetes/views/deploy/deployController.js +++ b/app/kubernetes/views/deploy/deployController.js @@ -5,7 +5,8 @@ import uuidv4 from 'uuid/v4'; import PortainerError from 'Portainer/error'; import { KubernetesDeployManifestTypes, KubernetesDeployBuildMethods, KubernetesDeployRequestMethods, RepositoryMechanismTypes } from 'Kubernetes/models/deploy'; -import { buildOption } from '@/portainer/components/box-selector'; +import { buildOption } from '@/portainer/components/BoxSelector'; + class KubernetesDeployController { /* @ngInject */ constructor($async, $state, $window, Authentication, ModalService, Notifications, KubernetesResourcePoolService, StackService, WebhookHelper, CustomTemplateService) { @@ -67,7 +68,8 @@ class KubernetesDeployController { this.getNamespacesAsync = this.getNamespacesAsync.bind(this); this.onChangeFormValues = this.onChangeFormValues.bind(this); this.buildAnalyticsProperties = this.buildAnalyticsProperties.bind(this); - this.onDeployTypeChange = this.onDeployTypeChange.bind(this); + this.onChangeMethod = this.onChangeMethod.bind(this); + this.onChangeDeployType = this.onChangeDeployType.bind(this); } buildAnalyticsProperties() { @@ -122,6 +124,19 @@ class KubernetesDeployController { } } + onChangeMethod(method) { + this.state.BuildMethod = method; + } + + onChangeDeployType(type) { + this.state.DeployType = type; + if (type == this.ManifestDeployTypes.COMPOSE) { + this.DeployMethod = 'compose'; + } else { + this.DeployMethod = 'manifest'; + } + } + disableDeploy() { const isGitFormInvalid = this.state.BuildMethod === KubernetesDeployBuildMethods.GIT && @@ -275,14 +290,6 @@ class KubernetesDeployController { return this.$async(this.getNamespacesAsync); } - onDeployTypeChange(value) { - if (value == this.ManifestDeployTypes.COMPOSE) { - this.DeployMethod = 'compose'; - } else { - this.DeployMethod = 'manifest'; - } - } - async uiCanExit() { if (this.formValues.EditorContent && this.state.isEditorDirty) { return this.ModalService.confirmWebEditorDiscard(); diff --git a/app/kubernetes/views/resource-pools/components/storage-class-switch/index.js b/app/kubernetes/views/resource-pools/components/storage-class-switch/index.js new file mode 100644 index 000000000..502d1614f --- /dev/null +++ b/app/kubernetes/views/resource-pools/components/storage-class-switch/index.js @@ -0,0 +1,14 @@ +import angular from 'angular'; +import controller from './storage-class-switch.controller.js'; + +export const storageClassSwitch = { + templateUrl: './storage-class-switch.html', + controller, + bindings: { + value: '<', + onChange: '<', + name: '<', + }, +}; + +angular.module('portainer.kubernetes').component('storageClassSwitch', storageClassSwitch); diff --git a/app/kubernetes/views/resource-pools/components/storage-class-switch/storage-class-switch.controller.js b/app/kubernetes/views/resource-pools/components/storage-class-switch/storage-class-switch.controller.js new file mode 100644 index 000000000..ddfc63312 --- /dev/null +++ b/app/kubernetes/views/resource-pools/components/storage-class-switch/storage-class-switch.controller.js @@ -0,0 +1,16 @@ +import { FeatureId } from '@/portainer/feature-flags/enums'; + +class StorageClassSwitchController { + /* @ngInject */ + constructor() { + this.featureId = FeatureId.K8S_RESOURCE_POOL_STORAGE_QUOTA; + + this.handleChange = this.handleChange.bind(this); + } + + handleChange(value) { + this.onChange(this.name, value); + } +} + +export default StorageClassSwitchController; diff --git a/app/kubernetes/views/resource-pools/components/storage-class-switch/storage-class-switch.html b/app/kubernetes/views/resource-pools/components/storage-class-switch/storage-class-switch.html new file mode 100644 index 000000000..dc52f5f00 --- /dev/null +++ b/app/kubernetes/views/resource-pools/components/storage-class-switch/storage-class-switch.html @@ -0,0 +1,12 @@ +
+
+ +
+
diff --git a/app/kubernetes/views/resource-pools/create/createResourcePool.html b/app/kubernetes/views/resource-pools/create/createResourcePool.html index 4ef095823..9023c8c0d 100644 --- a/app/kubernetes/views/resource-pools/create/createResourcePool.html +++ b/app/kubernetes/views/resource-pools/create/createResourcePool.html @@ -163,11 +163,12 @@
@@ -189,17 +190,9 @@ standard -
-
- -
-
+ + +
diff --git a/app/kubernetes/views/resource-pools/create/createResourcePoolController.js b/app/kubernetes/views/resource-pools/create/createResourcePoolController.js index a352b0a9a..b767d09a8 100644 --- a/app/kubernetes/views/resource-pools/create/createResourcePoolController.js +++ b/app/kubernetes/views/resource-pools/create/createResourcePoolController.js @@ -12,15 +12,16 @@ import KubernetesFormValidationHelper from 'Kubernetes/helpers/formValidationHel import { KubernetesFormValidationReferences } from 'Kubernetes/models/application/formValues'; import { KubernetesIngressClassTypes } from 'Kubernetes/ingress/constants'; -import { K8S_RESOURCE_POOL_LB_QUOTA, K8S_RESOURCE_POOL_STORAGE_QUOTA } from '@/portainer/feature-flags/feature-ids'; +import { FeatureId } from '@/portainer/feature-flags/enums'; class KubernetesCreateResourcePoolController { /* #region CONSTRUCTOR */ /* @ngInject */ - constructor($async, $state, Notifications, KubernetesNodeService, KubernetesResourcePoolService, KubernetesIngressService, Authentication, EndpointService) { + constructor($async, $state, $scope, Notifications, KubernetesNodeService, KubernetesResourcePoolService, KubernetesIngressService, Authentication, EndpointService) { Object.assign(this, { $async, $state, + $scope, Notifications, KubernetesNodeService, KubernetesResourcePoolService, @@ -30,11 +31,25 @@ class KubernetesCreateResourcePoolController { }); this.IngressClassTypes = KubernetesIngressClassTypes; - this.LBQuotaFeatureId = K8S_RESOURCE_POOL_LB_QUOTA; - this.StorageQuotaFeatureId = K8S_RESOURCE_POOL_STORAGE_QUOTA; + this.LBQuotaFeatureId = FeatureId.K8S_RESOURCE_POOL_LB_QUOTA; + + this.onToggleStorageQuota = this.onToggleStorageQuota.bind(this); + this.onToggleLoadBalancerQuota = this.onToggleLoadBalancerQuota.bind(this); } /* #endregion */ + onToggleStorageQuota(storageClassName, enabled) { + this.$scope.$evalAsync(() => { + this.formValues.StorageClasses = this.formValues.StorageClasses.map((sClass) => (sClass.Name !== storageClassName ? sClass : { ...sClass, Selected: enabled })); + }); + } + + onToggleLoadBalancerQuota(enabled) { + this.$scope.$evalAsync(() => { + this.formValues.UseLoadBalancersQuota = enabled; + }); + } + onChangeIngressHostname() { const state = this.state.duplicates.ingressHosts; const hosts = _.flatMap(this.formValues.IngressClasses, 'Hosts'); diff --git a/app/kubernetes/views/resource-pools/edit/resourcePool.html b/app/kubernetes/views/resource-pools/edit/resourcePool.html index efbf08b61..5b3a3cc6b 100644 --- a/app/kubernetes/views/resource-pools/edit/resourcePool.html +++ b/app/kubernetes/views/resource-pools/edit/resourcePool.html @@ -147,11 +147,12 @@
@@ -386,17 +387,9 @@ standard
-
-
- -
-
+ + + diff --git a/app/kubernetes/views/resource-pools/edit/resourcePoolController.js b/app/kubernetes/views/resource-pools/edit/resourcePoolController.js index 630cd5d8e..1e82c25fd 100644 --- a/app/kubernetes/views/resource-pools/edit/resourcePoolController.js +++ b/app/kubernetes/views/resource-pools/edit/resourcePoolController.js @@ -16,7 +16,7 @@ import KubernetesFormValidationHelper from 'Kubernetes/helpers/formValidationHel import { KubernetesIngressClassTypes } from 'Kubernetes/ingress/constants'; import KubernetesResourceQuotaConverter from 'Kubernetes/converters/resourceQuota'; import KubernetesNamespaceHelper from 'Kubernetes/helpers/namespaceHelper'; -import { K8S_RESOURCE_POOL_LB_QUOTA, K8S_RESOURCE_POOL_STORAGE_QUOTA } from '@/portainer/feature-flags/feature-ids'; +import { FeatureId } from '@/portainer/feature-flags/enums'; class KubernetesResourcePoolController { /* #region CONSTRUCTOR */ @@ -24,6 +24,7 @@ class KubernetesResourcePoolController { constructor( $async, $state, + $scope, Authentication, Notifications, LocalStorage, @@ -42,6 +43,7 @@ class KubernetesResourcePoolController { Object.assign(this, { $async, $state, + $scope, Authentication, Notifications, LocalStorage, @@ -61,11 +63,13 @@ class KubernetesResourcePoolController { this.IngressClassTypes = KubernetesIngressClassTypes; this.ResourceQuotaDefaults = KubernetesResourceQuotaDefaults; - this.LBQuotaFeatureId = K8S_RESOURCE_POOL_LB_QUOTA; - this.StorageQuotaFeatureId = K8S_RESOURCE_POOL_STORAGE_QUOTA; + this.LBQuotaFeatureId = FeatureId.K8S_RESOURCE_POOL_LB_QUOTA; + this.StorageQuotaFeatureId = FeatureId.K8S_RESOURCE_POOL_STORAGE_QUOTA; this.updateResourcePoolAsync = this.updateResourcePoolAsync.bind(this); this.getEvents = this.getEvents.bind(this); + this.onToggleLoadBalancersQuota = this.onToggleLoadBalancersQuota.bind(this); + this.onToggleStorageQuota = this.onToggleStorageQuota.bind(this); } /* #endregion */ @@ -80,6 +84,18 @@ class KubernetesResourcePoolController { } /* #endregion */ + onToggleLoadBalancersQuota(checked) { + return this.$scope.$evalAsync(() => { + this.formValues.UseLoadBalancersQuota = checked; + }); + } + + onToggleStorageQuota(storageClassName, enabled) { + this.$scope.$evalAsync(() => { + this.formValues.StorageClasses = this.formValues.StorageClasses.map((sClass) => (sClass.Name !== storageClassName ? sClass : { ...sClass, Selected: enabled })); + }); + } + /* #region INGRESS MANAGEMENT */ onChangeIngressHostname() { const state = this.state.duplicates.ingressHosts; diff --git a/app/portainer/__module.js b/app/portainer/__module.js index 6df82a2d5..d7992c645 100644 --- a/app/portainer/__module.js +++ b/app/portainer/__module.js @@ -5,6 +5,7 @@ import componentsModule from './components'; import settingsModule from './settings'; import featureFlagModule from './feature-flags'; import userActivityModule from './user-activity'; +import servicesModule from './services'; async function initAuthentication(authManager, Authentication, $rootScope, $state) { authManager.checkAuthOnRefresh(); @@ -22,12 +23,19 @@ async function initAuthentication(authManager, Authentication, $rootScope, $stat } angular - .module('portainer.app', ['portainer.oauth', 'portainer.rbac', componentsModule, settingsModule, featureFlagModule, userActivityModule, 'portainer.shared.datatable']) + .module('portainer.app', [ + 'portainer.oauth', + 'portainer.rbac', + componentsModule, + settingsModule, + featureFlagModule, + userActivityModule, + 'portainer.shared.datatable', + servicesModule, + ]) .config([ '$stateRegistryProvider', function ($stateRegistryProvider) { - 'use strict'; - var root = { name: 'root', abstract: true, @@ -56,18 +64,6 @@ angular controller: 'SidebarController', }, }, - resolve: { - featuresServiceInitialized: /* @ngInject */ function featuresServiceInitialized($async, featureService, Notifications) { - return $async(async () => { - try { - await featureService.init(); - } catch (e) { - Notifications.error('Failed initializing features service', e); - throw e; - } - }); - }, - }, }; var endpointRoot = { diff --git a/app/portainer/components/BEFeatureIndicator/BEFeatureIndicator.controller.ts b/app/portainer/components/BEFeatureIndicator/BEFeatureIndicator.controller.ts new file mode 100644 index 000000000..3520699ed --- /dev/null +++ b/app/portainer/components/BEFeatureIndicator/BEFeatureIndicator.controller.ts @@ -0,0 +1,24 @@ +import { FeatureId } from '@/portainer/feature-flags/enums'; + +import { getFeatureDetails } from './utils'; + +export default class BeIndicatorController { + limitedToBE?: boolean; + + url?: string; + + feature?: FeatureId; + + /* @ngInject */ + constructor() { + this.limitedToBE = false; + this.url = ''; + } + + $onInit() { + const { url, limitedToBE } = getFeatureDetails(this.feature); + + this.limitedToBE = limitedToBE; + this.url = url; + } +} diff --git a/app/portainer/components/be-feature-indicator/be-feature-indicator.css b/app/portainer/components/BEFeatureIndicator/BEFeatureIndicator.css similarity index 100% rename from app/portainer/components/be-feature-indicator/be-feature-indicator.css rename to app/portainer/components/BEFeatureIndicator/BEFeatureIndicator.css diff --git a/app/portainer/components/be-feature-indicator/be-feature-indicator.html b/app/portainer/components/BEFeatureIndicator/BEFeatureIndicator.html similarity index 100% rename from app/portainer/components/be-feature-indicator/be-feature-indicator.html rename to app/portainer/components/BEFeatureIndicator/BEFeatureIndicator.html diff --git a/app/portainer/components/BEFeatureIndicator/BEFeatureIndicator.stories.tsx b/app/portainer/components/BEFeatureIndicator/BEFeatureIndicator.stories.tsx new file mode 100644 index 000000000..d4bf97338 --- /dev/null +++ b/app/portainer/components/BEFeatureIndicator/BEFeatureIndicator.stories.tsx @@ -0,0 +1,25 @@ +import { Meta } from '@storybook/react'; + +import { Edition, FeatureId } from '@/portainer/feature-flags/enums'; +import { init as initFeatureService } from '@/portainer/feature-flags/feature-flags.service'; + +import { BEFeatureIndicator, Props } from './BEFeatureIndicator'; + +export default { + component: BEFeatureIndicator, + title: 'Components/BEFeatureIndicator', + argTypes: { + featureId: { + control: { type: 'select', options: Object.values(FeatureId) }, + }, + }, +} as Meta; + +// : JSX.IntrinsicAttributes & PropsWithChildren +function Template({ featureId }: Props) { + initFeatureService(Edition.CE); + + return ; +} + +export const Example = Template.bind({}); diff --git a/app/portainer/components/BEFeatureIndicator/BEFeatureIndicator.tsx b/app/portainer/components/BEFeatureIndicator/BEFeatureIndicator.tsx new file mode 100644 index 000000000..b5dffd773 --- /dev/null +++ b/app/portainer/components/BEFeatureIndicator/BEFeatureIndicator.tsx @@ -0,0 +1,33 @@ +import { PropsWithChildren } from 'react'; + +import { FeatureId } from '@/portainer/feature-flags/enums'; + +import { getFeatureDetails } from './utils'; + +export interface Props { + featureId?: FeatureId; +} + +export function BEFeatureIndicator({ + featureId, + children, +}: PropsWithChildren) { + const { url, limitedToBE } = getFeatureDetails(featureId); + + if (!limitedToBE) { + return null; + } + + return ( + + {children} + + Business Edition Feature + + ); +} diff --git a/app/portainer/components/BEFeatureIndicator/index.ts b/app/portainer/components/BEFeatureIndicator/index.ts new file mode 100644 index 000000000..05a1f628f --- /dev/null +++ b/app/portainer/components/BEFeatureIndicator/index.ts @@ -0,0 +1,14 @@ +import controller from './BEFeatureIndicator.controller'; + +import './BEFeatureIndicator.css'; + +export const beFeatureIndicatorAngular = { + templateUrl: './BEFeatureIndicator.html', + controller, + bindings: { + feature: '<', + }, + transclude: true, +}; + +export { BEFeatureIndicator } from './BEFeatureIndicator'; diff --git a/app/portainer/components/BEFeatureIndicator/utils.ts b/app/portainer/components/BEFeatureIndicator/utils.ts new file mode 100644 index 000000000..b2933c7b1 --- /dev/null +++ b/app/portainer/components/BEFeatureIndicator/utils.ts @@ -0,0 +1,15 @@ +import { FeatureId } from '@/portainer/feature-flags/enums'; +import { isLimitedToBE } from '@/portainer/feature-flags/feature-flags.service'; + +const BE_URL = 'https://www.portainer.io/business-upsell?from='; + +export function getFeatureDetails(featureId?: FeatureId) { + if (!featureId) { + return {}; + } + const url = `${BE_URL}${featureId}`; + + const limitedToBE = isLimitedToBE(featureId); + + return { url, limitedToBE }; +} diff --git a/app/portainer/components/box-selector/box-selector.css b/app/portainer/components/BoxSelector/BoxSelector.css similarity index 100% rename from app/portainer/components/box-selector/box-selector.css rename to app/portainer/components/BoxSelector/BoxSelector.css diff --git a/app/portainer/components/BoxSelector/BoxSelector.module.css b/app/portainer/components/BoxSelector/BoxSelector.module.css new file mode 100644 index 000000000..83fdb4f94 --- /dev/null +++ b/app/portainer/components/BoxSelector/BoxSelector.module.css @@ -0,0 +1,4 @@ +.root { + float: left; + width: 100%; +} diff --git a/app/portainer/components/BoxSelector/BoxSelector.stories.tsx b/app/portainer/components/BoxSelector/BoxSelector.stories.tsx new file mode 100644 index 000000000..7a9a7b473 --- /dev/null +++ b/app/portainer/components/BoxSelector/BoxSelector.stories.tsx @@ -0,0 +1,83 @@ +import { Meta } from '@storybook/react'; +import { useState } from 'react'; + +import { init as initFeatureService } from '@/portainer/feature-flags/feature-flags.service'; +import { Edition, FeatureId } from '@/portainer/feature-flags/enums'; + +import { BoxSelector } from './BoxSelector'; +import { BoxSelectorOption } from './types'; + +const meta: Meta = { + title: 'BoxSelector', + component: BoxSelector, +}; + +export default meta; + +export function Example() { + const [value, setValue] = useState(3); + const options: BoxSelectorOption[] = [ + { + description: 'description 1', + icon: 'fa fa-rocket', + id: '1', + value: 3, + label: 'option 1', + }, + { + description: 'description 2', + icon: 'fa fa-rocket', + id: '2', + value: 4, + label: 'option 2', + }, + ]; + + return ( + { + setValue(value); + }} + value={value} + options={options} + /> + ); +} + +export function LimitedFeature() { + initFeatureService(Edition.CE); + const [value, setValue] = useState(3); + const options: BoxSelectorOption[] = [ + { + description: 'description 1', + icon: 'fa fa-rocket', + id: '1', + value: 3, + label: 'option 1', + }, + { + description: 'description 2', + icon: 'fa fa-rocket', + id: '2', + value: 4, + label: 'option 2', + feature: FeatureId.ACTIVITY_AUDIT, + }, + ]; + + return ( + { + setValue(value); + }} + value={value} + options={options} + /> + ); +} + +// regular example + +// story with limited feature diff --git a/app/portainer/components/BoxSelector/BoxSelector.test.tsx b/app/portainer/components/BoxSelector/BoxSelector.test.tsx new file mode 100644 index 000000000..5a063886b --- /dev/null +++ b/app/portainer/components/BoxSelector/BoxSelector.test.tsx @@ -0,0 +1,59 @@ +import { render, fireEvent } from '@/react-tools/test-utils'; + +import { BoxSelector, Props } from './BoxSelector'; +import { BoxSelectorOption } from './types'; + +function renderDefault({ + options = [], + onChange = () => {}, + radioName = 'radio', + value, +}: Partial> = {}) { + return render( + + ); +} + +test('should render with the initial value selected and call onChange when clicking a different value', async () => { + const options: BoxSelectorOption[] = [ + { + description: 'description 1', + icon: 'fa fa-rocket', + id: '1', + value: 3, + label: 'option 1', + }, + { + description: 'description 2', + icon: 'fa fa-rocket', + id: '2', + value: 4, + label: 'option 2', + }, + ]; + + const onChange = jest.fn(); + const { getByLabelText } = renderDefault({ + options, + onChange, + value: options[0].value, + }); + + const item1 = getByLabelText(options[0].label, { + exact: false, + }) as HTMLInputElement; + expect(item1.checked).toBeTruthy(); + + const item2 = getByLabelText(options[1].label, { + exact: false, + }) as HTMLInputElement; + expect(item2.checked).toBeFalsy(); + + fireEvent.click(item2); + expect(onChange).toHaveBeenCalledWith(options[1].value, false); +}); diff --git a/app/portainer/components/BoxSelector/BoxSelector.tsx b/app/portainer/components/BoxSelector/BoxSelector.tsx new file mode 100644 index 000000000..32cd106c1 --- /dev/null +++ b/app/portainer/components/BoxSelector/BoxSelector.tsx @@ -0,0 +1,49 @@ +import clsx from 'clsx'; + +import type { FeatureId } from '@/portainer/feature-flags/enums'; + +import './BoxSelector.css'; +import styles from './BoxSelector.module.css'; +import { BoxSelectorItem } from './BoxSelectorItem'; +import { BoxSelectorOption } from './types'; + +export interface Props { + radioName: string; + value: T; + onChange(value: T, limitedToBE: boolean): void; + options: BoxSelectorOption[]; +} + +export function BoxSelector({ + radioName, + value, + options, + onChange, +}: Props) { + return ( +
+ {options.map((option) => ( + + ))} +
+ ); +} + +export function buildOption( + id: string, + icon: string, + label: string, + description: string, + value: T, + feature: FeatureId +): BoxSelectorOption { + return { id, icon, label, description, value, feature }; +} diff --git a/app/portainer/components/BoxSelector/BoxSelectorAngular.tsx b/app/portainer/components/BoxSelector/BoxSelectorAngular.tsx new file mode 100644 index 000000000..e09737746 --- /dev/null +++ b/app/portainer/components/BoxSelector/BoxSelectorAngular.tsx @@ -0,0 +1,49 @@ +import { + IComponentOptions, + IComponentController, + IFormController, + IScope, +} from 'angular'; + +class BoxSelectorController implements IComponentController { + formCtrl!: IFormController; + + onChange!: (value: string | number) => void; + + radioName!: string; + + $scope: IScope; + + /* @ngInject */ + constructor($scope: IScope) { + this.handleChange = this.handleChange.bind(this); + + this.$scope = $scope; + } + + handleChange(value: string | number, limitedToBE: boolean) { + this.$scope.$evalAsync(() => { + this.formCtrl.$setValidity(this.radioName, !limitedToBE, this.formCtrl); + this.onChange(value); + }); + } +} + +export const BoxSelectorAngular: IComponentOptions = { + template: ``, + bindings: { + value: '<', + onChange: '<', + options: '<', + radioName: '<', + }, + require: { + formCtrl: '^form', + }, + controller: BoxSelectorController, +}; diff --git a/app/portainer/components/box-selector/box-selector-item/box-selector-item.css b/app/portainer/components/BoxSelector/BoxSelectorItem.css similarity index 100% rename from app/portainer/components/box-selector/box-selector-item/box-selector-item.css rename to app/portainer/components/BoxSelector/BoxSelectorItem.css diff --git a/app/portainer/components/BoxSelector/BoxSelectorItem.stories.tsx b/app/portainer/components/BoxSelector/BoxSelectorItem.stories.tsx new file mode 100644 index 000000000..a3c518cda --- /dev/null +++ b/app/portainer/components/BoxSelector/BoxSelectorItem.stories.tsx @@ -0,0 +1,77 @@ +import { Meta } from '@storybook/react'; + +import { init as initFeatureService } from '@/portainer/feature-flags/feature-flags.service'; +import { Edition, FeatureId } from '@/portainer/feature-flags/enums'; + +import { BoxSelectorItem } from './BoxSelectorItem'; +import { BoxSelectorOption } from './types'; + +const meta: Meta = { + title: 'BoxSelector/Item', + args: { + selected: false, + description: 'description', + icon: 'fa-rocket', + label: 'label', + }, +}; + +export default meta; + +interface ExampleProps { + selected?: boolean; + description?: string; + icon?: string; + label?: string; + feature?: FeatureId; +} + +function Template({ + selected, + description = 'description', + icon, + label = 'label', + feature, +}: ExampleProps) { + const option: BoxSelectorOption = { + description, + icon: `fa ${icon}`, + id: 'id', + label, + value: 1, + feature, + }; + + return ( +
+ {}} + option={option} + radioName="radio" + selectedValue={selected ? option.value : 0} + /> +
+ ); +} + +export const Example = Template.bind({}); + +export function SelectedItem() { + return