diff --git a/.gitignore b/.gitignore index 8251fd4cd..09be8ccc0 100644 --- a/.gitignore +++ b/.gitignore @@ -6,9 +6,4 @@ Deploy/config/db/env Deploy/config/jobservice/env ui/ui *.pyc -jobservice/*.sql -jobservice/*.sh -jobservice/*.json -jobservice/jobservice - - +jobservice/test diff --git a/job/error/error.go b/job/error/error.go deleted file mode 100644 index 680b1c6c8..000000000 --- a/job/error/error.go +++ /dev/null @@ -1,23 +0,0 @@ -/* - Copyright (c) 2016 VMware, Inc. All Rights Reserved. - Licensed under the Apache License, Version 2.0 (the "License"); - you may not use this file except in compliance with the License. - You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - - Unless required by applicable law or agreed to in writing, software - distributed under the License is distributed on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - See the License for the specific language governing permissions and - limitations under the License. -*/ - -package error - -// RetryChecker checks whether a job should retry if encounters an error -type RetryChecker interface { - // Retry : if the error can be disappear after retrying the job, Retry - // returns true - Retry(error) bool -} diff --git a/job/replication/delete.go b/job/replication/delete.go index 1bec6378f..ce8ef6a98 100644 --- a/job/replication/delete.go +++ b/job/replication/delete.go @@ -74,6 +74,17 @@ func (d *Deleter) Exit() error { // Enter deletes repository or tags func (d *Deleter) Enter() (string, error) { + state, err := d.enter() + if err != nil && retry(err) { + d.logger.Info("waiting for retrying...") + return models.JobRetrying, nil + } + + return state, err + +} + +func (d *Deleter) enter() (string, error) { if len(d.tags) == 0 { tags, err := d.dstClient.ListTag() diff --git a/job/replication/error.go b/job/replication/error.go index 53c7ca219..197dabbd3 100644 --- a/job/replication/error.go +++ b/job/replication/error.go @@ -19,12 +19,10 @@ import ( "net" ) -// ReplicaRetryChecker determines whether a job should be retried when an error occurred -type ReplicaRetryChecker struct { -} - -// Retry ... -func (r *ReplicaRetryChecker) Retry(err error) bool { +func retry(err error) bool { + if err == nil { + return false + } return isTemporary(err) } diff --git a/job/replication/transfer.go b/job/replication/transfer.go index 28ad5aa41..4eee419f4 100644 --- a/job/replication/transfer.go +++ b/job/replication/transfer.go @@ -152,6 +152,17 @@ type Checker struct { // Enter check existence of project, if it does not exist, create it, // if it exists, check whether the user has write privilege to it. func (c *Checker) Enter() (string, error) { + state, err := c.enter() + if err != nil && retry(err) { + c.logger.Info("waiting for retrying...") + return models.JobRetrying, nil + } + + return state, err + +} + +func (c *Checker) enter() (string, error) { enter: exist, canWrite, err := c.projectExist() if err != nil { @@ -316,6 +327,17 @@ type ManifestPuller struct { // Enter pulls manifest of a tag and checks if all blobs exist in the destination registry func (m *ManifestPuller) Enter() (string, error) { + state, err := m.enter() + if err != nil && retry(err) { + m.logger.Info("waiting for retrying...") + return models.JobRetrying, nil + } + + return state, err + +} + +func (m *ManifestPuller) enter() (string, error) { if len(m.tags) == 0 { m.logger.Infof("no tag needs to be replicated, next state is \"finished\"") return models.JobFinished, nil @@ -389,6 +411,17 @@ type BlobTransfer struct { // Enter pulls blobs and then pushs them to destination registry. func (b *BlobTransfer) Enter() (string, error) { + state, err := b.enter() + if err != nil && retry(err) { + b.logger.Info("waiting for retrying...") + return models.JobRetrying, nil + } + + return state, err + +} + +func (b *BlobTransfer) enter() (string, error) { name := b.repository tag := b.tags[0] for _, blob := range b.blobs { @@ -417,6 +450,17 @@ type ManifestPusher struct { // exists, pushs it to destination registry. The checking operation is to avoid // the situation that the tag is deleted during the blobs transfering func (m *ManifestPusher) Enter() (string, error) { + state, err := m.enter() + if err != nil && retry(err) { + m.logger.Info("waiting for retrying...") + return models.JobRetrying, nil + } + + return state, err + +} + +func (m *ManifestPusher) enter() (string, error) { name := m.repository tag := m.tags[0] _, exist, err := m.srcClient.ManifestExist(tag) diff --git a/job/scheduler.go b/job/scheduler.go index fddfdcafe..a721f235e 100644 --- a/job/scheduler.go +++ b/job/scheduler.go @@ -1,23 +1,36 @@ /* - Copyright (c) 2016 VMware, Inc. All Rights Reserved. - Licensed under the Apache License, Version 2.0 (the "License"); - you may not use this file except in compliance with the License. - You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - - Unless required by applicable law or agreed to in writing, software - distributed under the License is distributed on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - See the License for the specific language governing permissions and - limitations under the License. + Copyright (c) 2016 VMware, Inc. All Rights Reserved. + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. */ package job +import ( + "github.com/vmware/harbor/utils/log" + "time" +) + var jobQueue = make(chan int64) // Schedule put a job id into job queue. func Schedule(jobID int64) { jobQueue <- jobID } + +// Reschedule is called by statemachine to retry a job +func Reschedule(jobID int64) { + log.Debugf("Job %d will be rescheduled in 5 minutes", jobID) + time.Sleep(5 * time.Minute) + log.Debugf("Rescheduling job %d", jobID) + Schedule(jobID) +} diff --git a/job/statehandlers.go b/job/statehandlers.go index 60e852422..d4a855e77 100644 --- a/job/statehandlers.go +++ b/job/statehandlers.go @@ -1,16 +1,16 @@ /* - Copyright (c) 2016 VMware, Inc. All Rights Reserved. - Licensed under the Apache License, Version 2.0 (the "License"); - you may not use this file except in compliance with the License. - You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - - Unless required by applicable law or agreed to in writing, software - distributed under the License is distributed on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - See the License for the specific language governing permissions and - limitations under the License. + Copyright (c) 2016 VMware, Inc. All Rights Reserved. + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. */ package job @@ -33,25 +33,10 @@ type StateHandler interface { Exit() error } -// DummyHandler is the default implementation of StateHander interface, which has empty Enter and Exit methods. -type DummyHandler struct { - JobID int64 -} - -// Enter ... -func (dh DummyHandler) Enter() (string, error) { - return "", nil -} - -// Exit ... -func (dh DummyHandler) Exit() error { - return nil -} - // StatusUpdater implements the StateHandler interface which updates the status of a job in DB when the job enters // a status. type StatusUpdater struct { - DummyHandler + JobID int64 State string } @@ -69,9 +54,34 @@ func (su StatusUpdater) Enter() (string, error) { return next, err } +// Exit ... +func (su StatusUpdater) Exit() error { + return nil +} + +// Retry handles a special "retrying" in which case it will update the status in DB and reschedule the job +// via scheduler +type Retry struct { + JobID int64 +} + +// Enter ... +func (jr Retry) Enter() (string, error) { + err := dao.UpdateRepJobStatus(jr.JobID, models.JobRetrying) + if err != nil { + log.Errorf("Failed to update state of job :%d to Retrying, error: %v", jr.JobID, err) + } + go Reschedule(jr.JobID) + return "", err +} + +// Exit ... +func (jr Retry) Exit() error { + return nil +} + // ImgPuller was for testing type ImgPuller struct { - DummyHandler img string logger *log.Logger } @@ -80,13 +90,17 @@ type ImgPuller struct { func (ip ImgPuller) Enter() (string, error) { ip.logger.Infof("I'm pretending to pull img:%s, then sleep 30s", ip.img) time.Sleep(30 * time.Second) - ip.logger.Infof("wake up from sleep....") - return "push-img", nil + ip.logger.Infof("wake up from sleep.... testing retry") + return models.JobRetrying, nil +} + +// Exit ... +func (ip ImgPuller) Exit() error { + return nil } // ImgPusher is a statehandler for testing type ImgPusher struct { - DummyHandler targetURL string logger *log.Logger } @@ -95,6 +109,11 @@ type ImgPusher struct { func (ip ImgPusher) Enter() (string, error) { ip.logger.Infof("I'm pretending to push img to:%s, then sleep 30s", ip.targetURL) time.Sleep(30 * time.Second) - ip.logger.Infof("wake up from sleep....") - return models.JobContinue, nil + ip.logger.Infof("wake up from sleep.... testing retry") + return models.JobRetrying, nil +} + +// Exit ... +func (ip ImgPusher) Exit() error { + return nil } diff --git a/job/statemachine.go b/job/statemachine.go index 959a9f797..8a1c007ff 100644 --- a/job/statemachine.go +++ b/job/statemachine.go @@ -179,6 +179,7 @@ func (sm *SM) Init() { models.JobError: struct{}{}, models.JobStopped: struct{}{}, models.JobCanceled: struct{}{}, + models.JobRetrying: struct{}{}, } } @@ -243,9 +244,11 @@ func (sm *SM) Reset(jid int64) error { sm.Transitions = make(map[string]map[string]struct{}) sm.CurrentState = models.JobPending - sm.AddTransition(models.JobPending, models.JobRunning, StatusUpdater{DummyHandler{JobID: sm.JobID}, models.JobRunning}) - sm.Handlers[models.JobError] = StatusUpdater{DummyHandler{JobID: sm.JobID}, models.JobError} - sm.Handlers[models.JobStopped] = StatusUpdater{DummyHandler{JobID: sm.JobID}, models.JobStopped} + sm.AddTransition(models.JobPending, models.JobRunning, StatusUpdater{sm.JobID, models.JobRunning}) + sm.AddTransition(models.JobRetrying, models.JobRunning, StatusUpdater{sm.JobID, models.JobRunning}) + sm.Handlers[models.JobError] = StatusUpdater{sm.JobID, models.JobError} + sm.Handlers[models.JobStopped] = StatusUpdater{sm.JobID, models.JobStopped} + sm.Handlers[models.JobRetrying] = Retry{sm.JobID} switch sm.Parms.Operation { case models.RepOpTransfer: @@ -259,6 +262,12 @@ func (sm *SM) Reset(jid int64) error { return err } +//for testing onlly +func addTestTransition(sm *SM) error { + sm.AddTransition(models.JobRunning, "pull-img", ImgPuller{img: sm.Parms.Repository, logger: sm.Logger}) + return nil +} + func addImgTransferTransition(sm *SM) error { base, err := replication.InitBaseHandler(sm.Parms.Repository, sm.Parms.LocalRegURL, config.UISecret(), sm.Parms.TargetURL, sm.Parms.TargetUsername, sm.Parms.TargetPassword, @@ -269,7 +278,7 @@ func addImgTransferTransition(sm *SM) error { sm.AddTransition(models.JobRunning, replication.StateCheck, &replication.Checker{BaseHandler: base}) sm.AddTransition(replication.StateCheck, replication.StatePullManifest, &replication.ManifestPuller{BaseHandler: base}) sm.AddTransition(replication.StatePullManifest, replication.StateTransferBlob, &replication.BlobTransfer{BaseHandler: base}) - sm.AddTransition(replication.StatePullManifest, models.JobFinished, &StatusUpdater{DummyHandler{JobID: sm.JobID}, models.JobFinished}) + sm.AddTransition(replication.StatePullManifest, models.JobFinished, &StatusUpdater{sm.JobID, models.JobFinished}) sm.AddTransition(replication.StateTransferBlob, replication.StatePushManifest, &replication.ManifestPusher{BaseHandler: base}) sm.AddTransition(replication.StatePushManifest, replication.StatePullManifest, &replication.ManifestPuller{BaseHandler: base}) return nil @@ -283,7 +292,7 @@ func addImgDeleteTransition(sm *SM) error { } sm.AddTransition(models.JobRunning, replication.StateDelete, deleter) - sm.AddTransition(replication.StateDelete, models.JobFinished, &StatusUpdater{DummyHandler{JobID: sm.JobID}, models.JobFinished}) + sm.AddTransition(replication.StateDelete, models.JobFinished, &StatusUpdater{sm.JobID, models.JobFinished}) return nil } diff --git a/jobservice/main.go b/jobservice/main.go index d960a0904..6f54b09e4 100644 --- a/jobservice/main.go +++ b/jobservice/main.go @@ -38,13 +38,13 @@ func resumeJobs() { if err != nil { log.Warningf("Failed to reset all running jobs to pending, error: %v", err) } - jobs, err := dao.GetRepJobByStatus(models.JobPending) + jobs, err := dao.GetRepJobByStatus(models.JobPending, models.JobRetrying) if err == nil { for _, j := range jobs { - log.Debugf("Rescheduling job: %d", j.ID) + log.Debugf("Resuming job: %d", j.ID) job.Schedule(j.ID) } } else { - log.Warningf("Failed to get pending jobs, error: %v", err) + log.Warningf("Failed to jobs to resume, error: %v", err) } } diff --git a/static/resources/css/header.css b/static/resources/css/header.css index ffad4be34..bf721c985 100644 --- a/static/resources/css/header.css +++ b/static/resources/css/header.css @@ -124,4 +124,8 @@ nav .container-custom { height: 1.2em; margin-bottom: 2px; vertical-align: middle; -} \ No newline at end of file +} + +a:hover, a:visited, a:link { + text-decoration: none; +} \ No newline at end of file diff --git a/static/resources/js/components/inline-help/inline-help.directive.html b/static/resources/js/components/inline-help/inline-help.directive.html new file mode 100644 index 000000000..3855f535b --- /dev/null +++ b/static/resources/js/components/inline-help/inline-help.directive.html @@ -0,0 +1,5 @@ + + + \ No newline at end of file diff --git a/static/resources/js/components/inline-help/inline-help.directive.js b/static/resources/js/components/inline-help/inline-help.directive.js new file mode 100644 index 000000000..bae34023d --- /dev/null +++ b/static/resources/js/components/inline-help/inline-help.directive.js @@ -0,0 +1,34 @@ +(function() { + + 'use strict'; + + angular + .module('harbor.inline.help') + .directive('inlineHelp', inlineHelp); + function InlineHelpController() { + var vm = this; + } + function inlineHelp() { + var directive = { + 'restrict': 'E', + 'templateUrl': '/static/resources/js/components/inline-help/inline-help.directive.html', + 'scope': { + 'helpTitle': '@', + 'content': '@' + }, + 'link': link, + 'controller': InlineHelpController, + 'controllerAs': 'vm', + 'bindToController': true + }; + return directive; + function link(scope, element, attr, ctrl) { + element.popover({ + 'title': ctrl.helpTitle, + 'content': ctrl.content, + 'html': true + }); + } + } + +})(); \ No newline at end of file diff --git a/static/resources/js/components/inline-help/inline-help.module.js b/static/resources/js/components/inline-help/inline-help.module.js new file mode 100644 index 000000000..38e580c21 --- /dev/null +++ b/static/resources/js/components/inline-help/inline-help.module.js @@ -0,0 +1,8 @@ +(function() { + + 'use strict'; + + angular + .module('harbor.inline.help', []); + +})(); \ No newline at end of file diff --git a/static/resources/js/components/optional-menu/optional-menu.directive.js b/static/resources/js/components/optional-menu/optional-menu.directive.js index 376d0733a..62198c6f2 100644 --- a/static/resources/js/components/optional-menu/optional-menu.directive.js +++ b/static/resources/js/components/optional-menu/optional-menu.directive.js @@ -45,7 +45,7 @@ function optionalMenu() { var directive = { 'restrict': 'E', - 'templateUrl': '/optional_menu', + 'templateUrl': '/optional_menu?timestamp=' + new Date().getTime(), 'scope': true, 'controller': OptionalMenuController, 'controllerAs': 'vm', diff --git a/static/resources/js/components/project-member/add-project-member.directive.html b/static/resources/js/components/project-member/add-project-member.directive.html index 3a7835bc3..675f6b5ba 100644 --- a/static/resources/js/components/project-member/add-project-member.directive.html +++ b/static/resources/js/components/project-member/add-project-member.directive.html @@ -12,9 +12,10 @@
-    + +    -  //role.name//   +  //role.name | tr//  
diff --git a/static/resources/js/components/project-member/project-member.config.js b/static/resources/js/components/project-member/project-member.config.js index 91f76fc63..cc2f395e9 100644 --- a/static/resources/js/components/project-member/project-member.config.js +++ b/static/resources/js/components/project-member/project-member.config.js @@ -9,15 +9,15 @@ function roles() { return [ - {'id': '1', 'name': 'Project Admin', 'roleName': 'projectAdmin'}, - {'id': '2', 'name': 'Developer', 'roleName': 'developer'}, - {'id': '3', 'name': 'Guest', 'roleName': 'guest'} + {'id': '1', 'name': 'project_admin', 'roleName': 'projectAdmin'}, + {'id': '2', 'name': 'developer', 'roleName': 'developer'}, + {'id': '3', 'name': 'guest', 'roleName': 'guest'} ]; } - getRole.$inject = ['roles']; + getRole.$inject = ['roles', '$filter', 'trFilter']; - function getRole(roles) { + function getRole(roles, $filter, trFilter) { var r = roles(); return get; function get(query) { diff --git a/static/resources/js/components/project-member/switch-role.directive.html b/static/resources/js/components/project-member/switch-role.directive.html index 13721d691..0f1228a75 100644 --- a/static/resources/js/components/project-member/switch-role.directive.html +++ b/static/resources/js/components/project-member/switch-role.directive.html @@ -1,5 +1,5 @@ - //vm.currentRole.name// - \ No newline at end of file diff --git a/static/resources/js/components/project/add-project.directive.html b/static/resources/js/components/project/add-project.directive.html index b346c3c4a..16fa3c9d5 100644 --- a/static/resources/js/components/project/add-project.directive.html +++ b/static/resources/js/components/project/add-project.directive.html @@ -13,7 +13,8 @@
-  // 'public' | tr // +  // 'public' | tr // +
diff --git a/static/resources/js/components/replication/create-policy.directive.js b/static/resources/js/components/replication/create-policy.directive.js index 2c776ee41..100273cac 100644 --- a/static/resources/js/components/replication/create-policy.directive.js +++ b/static/resources/js/components/replication/create-policy.directive.js @@ -409,7 +409,7 @@ if(!ctrl.toggleErrorMessage) { element.find('#createPolicyModal').modal('hide'); } - }, 50); + }, 150); } } } diff --git a/static/resources/js/harbor.config.js b/static/resources/js/harbor.config.js index cef6741cd..2196a1f57 100644 --- a/static/resources/js/harbor.config.js +++ b/static/resources/js/harbor.config.js @@ -21,10 +21,10 @@ return { 'responseError': function(rejection) { var pathname = $window.location.pathname; - var exclusions = ['/', '/search', '/reset_password', '/sign_up', '/forgot_password']; + var exclusion = ['/', '/search', '/reset_password', '/sign_up', '/forgot_password', '/repository']; var isExcluded = false; - for(var i in exclusions) { - if(exclusions[i] === pathname) { + for(var i in exclusion) { + if(exclusion[i] === pathname) { isExcluded = true; break; } diff --git a/static/resources/js/harbor.module.js b/static/resources/js/harbor.module.js index 6aadd7f70..3b7e85adc 100644 --- a/static/resources/js/harbor.module.js +++ b/static/resources/js/harbor.module.js @@ -44,6 +44,7 @@ 'harbor.validator', 'harbor.replication', 'harbor.system.management', - 'harbor.loading.progress' + 'harbor.loading.progress', + 'harbor.inline.help' ]); })(); \ No newline at end of file diff --git a/static/resources/js/layout/navigation/navigation-details.directive.js b/static/resources/js/layout/navigation/navigation-details.directive.js index a3cb6245e..77bceb099 100644 --- a/static/resources/js/layout/navigation/navigation-details.directive.js +++ b/static/resources/js/layout/navigation/navigation-details.directive.js @@ -23,7 +23,7 @@ function navigationDetails() { var directive = { restrict: 'E', - templateUrl: '/navigation_detail', + templateUrl: '/navigation_detail?timestamp=' + new Date().getTime(), link: link, scope: { 'target': '=' diff --git a/static/resources/js/layout/navigation/navigation-header.directive.js b/static/resources/js/layout/navigation/navigation-header.directive.js index 9d5464ca1..ceb9cecc7 100644 --- a/static/resources/js/layout/navigation/navigation-header.directive.js +++ b/static/resources/js/layout/navigation/navigation-header.directive.js @@ -16,7 +16,7 @@ function navigationHeader() { var directive = { restrict: 'E', - templateUrl: '/navigation_header', + templateUrl: '/navigation_header?timestamp=' + new Date().getTime(), link: link, scope: true, controller: NavigationHeaderController, diff --git a/static/resources/js/services/i18n/locale_messages_en-US.js b/static/resources/js/services/i18n/locale_messages_en-US.js index f6906d5f6..b14060f44 100644 --- a/static/resources/js/services/i18n/locale_messages_en-US.js +++ b/static/resources/js/services/i18n/locale_messages_en-US.js @@ -235,5 +235,14 @@ var locale_messages = { 'failed_to_delete_destination': 'Failed to delete destination.', 'failed_to_create_destination': 'Failed to create destination.', 'failed_to_update_destination': 'Failed to update destination.', + 'project_admin': 'Project Admin', + 'developer': 'Developer', + 'guest': 'Guest', + 'inline_help_role_title': 'The Definitions of Roles', + 'inline_help_role': 'Project Admin: When creating a new project, you will be assigned the "ProjectAdmin" role to the project. Besides read-write privileges, the "ProjectAdmin" also has some management privileges, such as adding and removing members.
' + + 'Developer: Developer has read and write privileges for a project.
' + + 'Guest: Guest has read-only privilege for a specified project.', + 'inline_help_publicity_title': 'Publicity of Project', + 'inline_help_publicity': 'Setting the project as public.' }; \ No newline at end of file diff --git a/static/resources/js/services/i18n/locale_messages_zh-CN.js b/static/resources/js/services/i18n/locale_messages_zh-CN.js index a9eb222e7..3d34db42a 100644 --- a/static/resources/js/services/i18n/locale_messages_zh-CN.js +++ b/static/resources/js/services/i18n/locale_messages_zh-CN.js @@ -234,5 +234,14 @@ var locale_messages = { 'failed_to_update_replication_policy': '修改复制策略失败。', 'failed_to_delete_destination': '删除目标失败。', 'failed_to_create_destination': '创建目标失败。', - 'failed_to_update_destination': '修改目标失败。' + 'failed_to_update_destination': '修改目标失败。', + 'project_admin': '项目管理员', + 'developer': '开发人员', + 'guest': '来宾用户', + 'inline_help_role_title': '角色定义', + 'inline_help_role': '项目管理员: 当创建一个新项目后,您将被指派一个“项目管理员”角色。除了具备读/写权限外,“项目管理员”还拥有添加、删除其他项目成员的管理权限。
'+ + '开发人员: “开发人员” 拥有一个项目的读/写权限。
' + + '来宾用户: “来宾用户”拥有特定项目的只读权限。', + 'inline_help_publicity_title': '公开项目', + 'inline_help_publicity': '设置该项目为公开。' }; \ No newline at end of file diff --git a/views/sections/header-include.htm b/views/sections/header-include.htm index 0128f928d..5f83064bf 100644 --- a/views/sections/header-include.htm +++ b/views/sections/header-include.htm @@ -237,4 +237,7 @@ - \ No newline at end of file + + + + \ No newline at end of file