diff --git a/Makefile b/Makefile index d42a8c97e..75ea0af33 100644 --- a/Makefile +++ b/Makefile @@ -306,6 +306,8 @@ modify_composefile_clair: @cp $(DOCKERCOMPOSEFILEPATH)/$(DOCKERCOMPOSECLAIRTPLFILENAME) $(DOCKERCOMPOSEFILEPATH)/$(DOCKERCOMPOSECLAIRFILENAME) @$(SEDCMD) -i 's/__postgresql_version__/$(CLAIRDBVERSION)/g' $(DOCKERCOMPOSEFILEPATH)/$(DOCKERCOMPOSECLAIRFILENAME) @$(SEDCMD) -i 's/__clair_version__/$(CLAIRVERSION)-$(VERSIONTAG)/g' $(DOCKERCOMPOSEFILEPATH)/$(DOCKERCOMPOSECLAIRFILENAME) + @cp $(DOCKERCOMPOSEFILEPATH)/ha/$(DOCKERCOMPOSECLAIRTPLFILENAME) $(DOCKERCOMPOSEFILEPATH)/ha/$(DOCKERCOMPOSECLAIRFILENAME) + @$(SEDCMD) -i 's/__clair_version__/$(CLAIRVERSION)-$(VERSIONTAG)/g' $(DOCKERCOMPOSEFILEPATH)/ha/$(DOCKERCOMPOSECLAIRFILENAME) modify_sourcefiles: @echo "change mode of source files." diff --git a/make/common/templates/registry/config.yml b/make/common/templates/registry/config.yml index 4fd13d26d..c49805a04 100644 --- a/make/common/templates/registry/config.yml +++ b/make/common/templates/registry/config.yml @@ -4,32 +4,31 @@ log: fields: service: registry storage: - cache: - layerinfo: inmemory - filesystem: - rootdirectory: /storage - maintenance: - uploadpurging: - enabled: false - delete: - enabled: true + cache: + layerinfo: inmemory + filesystem: + rootdirectory: /storage + maintenance: + uploadpurging: + enabled: false + delete: + enabled: true http: - addr: :5000 - secret: placeholder - debug: - addr: localhost:5001 + addr: :5000 + secret: placeholder + debug: + addr: localhost:5001 auth: token: issuer: harbor-token-issuer realm: $ui_url/service/token rootcertbundle: /etc/registry/root.crt service: harbor-registry - notifications: endpoints: - - name: harbor - disabled: false - url: http://ui:8080/service/notifications - timeout: 3000ms - threshold: 5 - backoff: 1s + - name: harbor + disabled: false + url: http://ui:8080/service/notifications + timeout: 3000ms + threshold: 5 + backoff: 1s diff --git a/make/common/templates/registry/config_ha.yml b/make/common/templates/registry/config_ha.yml index bedf6f482..f3b04fcb1 100644 --- a/make/common/templates/registry/config_ha.yml +++ b/make/common/templates/registry/config_ha.yml @@ -4,14 +4,14 @@ log: fields: service: registry storage: - cache: - layerinfo: redis - Place_holder_for_Storage_configureation - maintenance: - uploadpurging: - enabled: false - delete: - enabled: true + cache: + layerinfo: redis + Place_holder_for_Storage_configureation + maintenance: + uploadpurging: + enabled: false + delete: + enabled: true redis: addr: $redis_url db: 0 @@ -24,10 +24,10 @@ redis: idletimeout: 300s http: - addr: :5000 - secret: placeholder - debug: - addr: localhost:5001 + addr: :5000 + secret: placeholder + debug: + addr: localhost:5001 auth: token: issuer: harbor-token-issuer @@ -37,9 +37,9 @@ auth: notifications: endpoints: - - name: harbor - disabled: false - url: http://ui:8080/service/notifications - timeout: 3000ms - threshold: 5 - backoff: 1s + - name: harbor + disabled: false + url: http://ui:8080/service/notifications + timeout: 3000ms + threshold: 5 + backoff: 1s diff --git a/make/docker-compose.tpl b/make/docker-compose.tpl index 26a87a6e3..a6755236b 100644 --- a/make/docker-compose.tpl +++ b/make/docker-compose.tpl @@ -76,7 +76,7 @@ services: volumes: - ./common/config/ui/app.conf:/etc/ui/app.conf:z - ./common/config/ui/private_key.pem:/etc/ui/private_key.pem:z - - ./common/config/ui/certificates/:/etc/ui/certifates/ + - ./common/config/ui/certificates/:/etc/ui/certificates/ - /data/secretkey:/etc/ui/key:z - /data/ca_download/:/etc/ui/ca/:z - /data/psc/:/etc/ui/token/:z diff --git a/make/ha/docker-compose.clair.tpl b/make/ha/docker-compose.clair.tpl new file mode 100644 index 000000000..3a5590e0c --- /dev/null +++ b/make/ha/docker-compose.clair.tpl @@ -0,0 +1,32 @@ +version: '2' +services: + ui: + networks: + harbor-clair: + aliases: + - harbor-ui + jobservice: + networks: + - harbor-clair + registry: + networks: + - harbor-clair + clair: + networks: + - harbor-clair + container_name: clair + image: vmware/clair-photon:__clair_version__ + restart: always + cpu_quota: 150000 + depends_on: + - log + volumes: + - ./common/config/clair:/config + logging: + driver: "syslog" + options: + syslog-address: "tcp://127.0.0.1:1514" + tag: "clair" +networks: + harbor-clair: + external: false diff --git a/make/harbor.cfg b/make/harbor.cfg index 8df07e977..156a6acfa 100644 --- a/make/harbor.cfg +++ b/make/harbor.cfg @@ -142,4 +142,5 @@ uaa_endpoint = uaa.mydomain.org uaa_clientid = id uaa_clientsecret = secret uaa_verify_cert = true +uaa_ca_cert = /path/to/ca.pem ############# diff --git a/make/install.sh b/make/install.sh index f80805618..069c4b722 100755 --- a/make/install.sh +++ b/make/install.sh @@ -165,7 +165,7 @@ if [ $with_notary ] && [ ! $harbor_ha ] then prepare_para="${prepare_para} --with-notary" fi -if [ $with_clair ] && [ ! $harbor_ha ] +if [ $with_clair ] then prepare_para="${prepare_para} --with-clair" fi @@ -182,7 +182,7 @@ if [ $with_notary ] && [ ! $harbor_ha ] then docker_compose_list="${docker_compose_list} -f docker-compose.notary.yml" fi -if [ $with_clair ] && [ ! $harbor_ha ] +if [ $with_clair ] then docker_compose_list="${docker_compose_list} -f docker-compose.clair.yml" fi @@ -199,6 +199,8 @@ if [ $harbor_ha ] then mv docker-compose.yml docker-compose.yml.bak cp ha/docker-compose.yml docker-compose.yml + mv docker-compose.clair.yml docker-compose.clair.yml.bak + cp ha/docker-compose.clair.yml docker-compose.clair.yml fi docker-compose $docker_compose_list up -d diff --git a/make/prepare b/make/prepare index 226fcc707..ba5e7476c 100755 --- a/make/prepare +++ b/make/prepare @@ -30,8 +30,13 @@ def validate(conf, args): redis_url = rcp.get("configuration", "redis_url") if redis_url is None or len(redis_url) < 1: raise Exception("Error: In HA mode redis is required redis_url need to point to an redis cluster") - if args.notary_mode or args.clair_mode: - raise Exception("Error: HA mode doesn't support clair and notary currently") + if args.notary_mode: + raise Exception("Error: HA mode doesn't support Notary currently") + if args.clair_mode: + clair_db_host = rcp.get("configuration", "clair_db_host") + if "postgres" == clair_db_host: + raise Exception("Error: In HA mode, clair_db_host in harbor.cfg needs to point to an external Postgres DB address.") + cert_path = rcp.get("configuration", "ssl_cert") cert_key_path = rcp.get("configuration", "ssl_cert_key") shared_cert_key = os.path.join(base_dir, "ha", os.path.basename(cert_key_path)) @@ -245,6 +250,7 @@ uaa_endpoint = rcp.get("configuration", "uaa_endpoint") uaa_clientid = rcp.get("configuration", "uaa_clientid") uaa_clientsecret = rcp.get("configuration", "uaa_clientsecret") uaa_verify_cert = rcp.get("configuration", "uaa_verify_cert") +uaa_ca_cert = rcp.get("configuration", "uaa_ca_cert") secret_key = get_secret_key(secretkey_path) log_rotate_count = rcp.get("configuration", "log_rotate_count") @@ -275,6 +281,7 @@ log_config_dir = prep_conf_dir (config_dir, "log") adminserver_conf_env = os.path.join(config_dir, "adminserver", "env") ui_conf_env = os.path.join(config_dir, "ui", "env") ui_conf = os.path.join(config_dir, "ui", "app.conf") +ui_cert_dir = os.path.join(config_dir, "ui", "certificates") jobservice_conf = os.path.join(config_dir, "jobservice", "app.conf") registry_conf = os.path.join(config_dir, "registry", "config.yml") db_conf_env = os.path.join(config_dir, "db", "env") @@ -382,6 +389,16 @@ shutil.copyfile(os.path.join(templates_dir, "jobservice", "app.conf"), jobservic print("Generated configuration file: %s" % ui_conf) shutil.copyfile(os.path.join(templates_dir, "ui", "app.conf"), ui_conf) +if auth_mode == "uaa_auth": + if os.path.isfile(uaa_ca_cert): + if not os.path.isdir(ui_cert_dir): + os.makedirs(ui_cert_dir, mode=0o600) + ui_uaa_ca = os.path.join(ui_cert_dir, "uaa_ca.pem") + print("Copying UAA CA cert to %s" % ui_uaa_ca) + shutil.copyfile(uaa_ca_cert, ui_uaa_ca) + else: + print("Can not find UAA CA cert: %s, skip" % uaa_ca_cert) + def validate_crt_subj(dirty_subj): subj_list = [item for item in dirty_subj.strip().split("/") \ diff --git a/src/common/dao/config_test.go b/src/common/dao/config_test.go index d48628ddb..447dff871 100644 --- a/src/common/dao/config_test.go +++ b/src/common/dao/config_test.go @@ -46,7 +46,7 @@ func TestAuthModeCanBeModified(t *testing.T) { t.Fatalf("failed to register user: %v", err) } defer func(id int64) { - if err := deleteUser(id); err != nil { + if err := CleanUser(id); err != nil { t.Fatalf("failed to delete user %d: %v", id, err) } }(id) @@ -68,4 +68,4 @@ func TestAuthModeCanBeModified(t *testing.T) { t.Errorf("unexpected result: %t != %t", flag, false) } } -} \ No newline at end of file +} diff --git a/src/common/dao/user.go b/src/common/dao/user.go index 17ed5e37f..2ce881ad5 100644 --- a/src/common/dao/user.go +++ b/src/common/dao/user.go @@ -258,9 +258,12 @@ func DeleteUser(userID int) error { } // ChangeUserProfile ... -func ChangeUserProfile(user models.User) error { +func ChangeUserProfile(user models.User, cols ...string) error { o := GetOrmer() - if _, err := o.Update(&user, "Email", "Realname", "Comment"); err != nil { + if len(cols) == 0 { + cols = []string{"Email", "Realname", "Comment"} + } + if _, err := o.Update(&user, cols...); err != nil { log.Errorf("update user failed, error: %v", err) return err } @@ -290,3 +293,12 @@ func OnBoardUser(u *models.User) error { } return nil } + +//CleanUser - Clean this user information from DB +func CleanUser(id int64) error { + if _, err := GetOrmer().QueryTable(&models.User{}). + Filter("UserID", id).Delete(); err != nil { + return err + } + return nil +} diff --git a/src/common/dao/user_test.go b/src/common/dao/user_test.go index f8bf76e92..edcbce6a6 100644 --- a/src/common/dao/user_test.go +++ b/src/common/dao/user_test.go @@ -39,7 +39,7 @@ func TestDeleteUser(t *testing.T) { t.Fatalf("failed to register user: %v", err) } defer func(id int64) { - if err := deleteUser(id); err != nil { + if err := CleanUser(id); err != nil { t.Fatalf("failed to delete user %d: %v", id, err) } }(id) @@ -88,13 +88,5 @@ func TestOnBoardUser(t *testing.T) { err = OnBoardUser(u) assert.Nil(err) assert.True(u.UserID == id) - deleteUser(int64(id)) -} - -func deleteUser(id int64) error { - if _, err := GetOrmer().QueryTable(&models.User{}). - Filter("UserID", id).Delete(); err != nil { - return err - } - return nil + CleanUser(int64(id)) } diff --git a/src/common/models/models_test.go b/src/common/models/models_test.go deleted file mode 100644 index 822312399..000000000 --- a/src/common/models/models_test.go +++ /dev/null @@ -1,21 +0,0 @@ -// Copyright (c) 2017 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 models - -import ( - "testing" -) - -func TestMain(m *testing.M) { -} diff --git a/src/common/models/replication_job.go b/src/common/models/replication_job.go index 8c5744800..8c14234d9 100644 --- a/src/common/models/replication_job.go +++ b/src/common/models/replication_job.go @@ -89,14 +89,15 @@ func (r *RepTarget) Valid(v *validation.Validation) { v.SetError("name", "max length is 64") } - if len(r.URL) == 0 { - v.SetError("endpoint", "can not be empty") - } - - r.URL = utils.FormatEndpoint(r.URL) - - if len(r.URL) > 64 { - v.SetError("endpoint", "max length is 64") + url, err := utils.ParseEndpoint(r.URL) + if err != nil { + v.SetError("endpoint", err.Error()) + } else { + // Prevent SSRF security issue #3755 + r.URL = url.Scheme + "://" + url.Host + url.Path + if len(r.URL) > 64 { + v.SetError("endpoint", "max length is 64") + } } // password is encoded using base64, the length of this field diff --git a/src/common/models/target_test.go b/src/common/models/target_test.go new file mode 100644 index 000000000..f8bf2ea92 --- /dev/null +++ b/src/common/models/target_test.go @@ -0,0 +1,131 @@ +// Copyright (c) 2017 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 models + +import ( + "testing" + + "github.com/astaxie/beego/validation" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestValidOfTarget(t *testing.T) { + cases := []struct { + target RepTarget + err bool + expected RepTarget + }{ + // name is null + { + RepTarget{ + Name: "", + }, + true, + RepTarget{}}, + + // url is null + { + RepTarget{ + Name: "endpoint01", + URL: "", + }, + true, + RepTarget{}, + }, + + // invalid url + { + RepTarget{ + Name: "endpoint01", + URL: "ftp://example.com", + }, + true, + RepTarget{}, + }, + + // invalid url + { + RepTarget{ + Name: "endpoint01", + URL: "ftp://example.com", + }, + true, + RepTarget{}, + }, + + // valid url + { + RepTarget{ + Name: "endpoint01", + URL: "example.com", + }, + false, + RepTarget{ + Name: "endpoint01", + URL: "http://example.com", + }, + }, + + // valid url + { + RepTarget{ + Name: "endpoint01", + URL: "http://example.com", + }, + false, + RepTarget{ + Name: "endpoint01", + URL: "http://example.com", + }, + }, + + // valid url + { + RepTarget{ + Name: "endpoint01", + URL: "https://example.com", + }, + false, + RepTarget{ + Name: "endpoint01", + URL: "https://example.com", + }, + }, + + // valid url + { + RepTarget{ + Name: "endpoint01", + URL: "http://example.com/redirect?key=value", + }, + false, + RepTarget{ + Name: "endpoint01", + URL: "http://example.com/redirect", + }}, + } + + for _, c := range cases { + v := &validation.Validation{} + c.target.Valid(v) + if c.err { + require.True(t, v.HasErrors()) + continue + } + require.False(t, v.HasErrors()) + assert.Equal(t, c.expected, c.target) + } +} diff --git a/src/common/utils/registry/registry.go b/src/common/utils/registry/registry.go index b84dabb97..bbd351309 100644 --- a/src/common/utils/registry/registry.go +++ b/src/common/utils/registry/registry.go @@ -129,7 +129,7 @@ func (r *Registry) Catalog() ([]string, error) { // Ping ... func (r *Registry) Ping() error { - req, err := http.NewRequest("GET", buildPingURL(r.Endpoint.String()), nil) + req, err := http.NewRequest(http.MethodHead, buildPingURL(r.Endpoint.String()), nil) if err != nil { return err } diff --git a/src/common/utils/registry/registry_test.go b/src/common/utils/registry/registry_test.go index 4f62ba595..b4204aaa3 100644 --- a/src/common/utils/registry/registry_test.go +++ b/src/common/utils/registry/registry_test.go @@ -28,7 +28,7 @@ import ( func TestPing(t *testing.T) { server := test.NewServer( &test.RequestHandlerMapping{ - Method: "GET", + Method: http.MethodHead, Pattern: "/v2/", Handler: test.Handler(nil), }) diff --git a/src/common/utils/uaa/client.go b/src/common/utils/uaa/client.go index 8aab55027..07da2e792 100644 --- a/src/common/utils/uaa/client.go +++ b/src/common/utils/uaa/client.go @@ -22,6 +22,7 @@ import ( "fmt" "io/ioutil" "net/http" + "os" "strings" "github.com/vmware/harbor/src/common/utils/log" @@ -179,16 +180,20 @@ func NewDefaultClient(cfg *ClientConfig) (Client, error) { InsecureSkipVerify: cfg.SkipTLSVerify, } if !cfg.SkipTLSVerify && len(cfg.CARootPath) > 0 { - content, err := ioutil.ReadFile(cfg.CARootPath) - if err != nil { - return nil, err - } - pool := x509.NewCertPool() - //Do not throw error if the certificate is malformed, so we can put a place holder. - if ok := pool.AppendCertsFromPEM(content); !ok { - log.Warningf("Failed to append certificate to cert pool, cert path: %s", cfg.CARootPath) + if _, err := os.Stat(cfg.CARootPath); !os.IsNotExist(err) { + content, err := ioutil.ReadFile(cfg.CARootPath) + if err != nil { + return nil, err + } + pool := x509.NewCertPool() + //Do not throw error if the certificate is malformed, so we can put a place holder. + if ok := pool.AppendCertsFromPEM(content); !ok { + log.Warningf("Failed to append certificate to cert pool, cert path: %s", cfg.CARootPath) + } else { + tc.RootCAs = pool + } } else { - tc.RootCAs = pool + log.Warningf("The root certificate file %s is not found, skip configuring root cert in UAA client.", cfg.CARootPath) } } hc := &http.Client{ diff --git a/src/common/utils/uaa/client_test.go b/src/common/utils/uaa/client_test.go index 774392106..f56440a56 100644 --- a/src/common/utils/uaa/client_test.go +++ b/src/common/utils/uaa/client_test.go @@ -98,7 +98,7 @@ func TestNewClientWithCACert(t *testing.T) { CARootPath: "/notexist", } _, err := NewDefaultClient(cfg) - assert.NotNil(err) + assert.Nil(err) //Skip if it's malformed. cfg.CARootPath = path.Join(currPath(), "test", "non-ca.pem") _, err = NewDefaultClient(cfg) diff --git a/src/common/utils/utils.go b/src/common/utils/utils.go index 01be8ac45..0cc64b402 100644 --- a/src/common/utils/utils.go +++ b/src/common/utils/utils.go @@ -29,27 +29,24 @@ import ( "github.com/vmware/harbor/src/common/utils/log" ) -// FormatEndpoint formats endpoint -func FormatEndpoint(endpoint string) string { - endpoint = strings.TrimSpace(endpoint) +// ParseEndpoint parses endpoint to a URL +func ParseEndpoint(endpoint string) (*url.URL, error) { + endpoint = strings.Trim(endpoint, " ") endpoint = strings.TrimRight(endpoint, "/") - if !strings.HasPrefix(endpoint, "http://") && - !strings.HasPrefix(endpoint, "https://") { + if len(endpoint) == 0 { + return nil, fmt.Errorf("empty URL") + } + i := strings.Index(endpoint, "://") + if i >= 0 { + scheme := endpoint[:i] + if scheme != "http" && scheme != "https" { + return nil, fmt.Errorf("invalid scheme: %s", scheme) + } + } else { endpoint = "http://" + endpoint } - return endpoint -} - -// ParseEndpoint parses endpoint to a URL -func ParseEndpoint(endpoint string) (*url.URL, error) { - endpoint = FormatEndpoint(endpoint) - - u, err := url.Parse(endpoint) - if err != nil { - return nil, err - } - return u, nil + return url.ParseRequestURI(endpoint) } // ParseRepository splits a repository into two parts: project and rest diff --git a/src/common/utils/utils_test.go b/src/common/utils/utils_test.go index 24539fc54..7dcabc527 100644 --- a/src/common/utils/utils_test.go +++ b/src/common/utils/utils_test.go @@ -23,37 +23,30 @@ import ( "time" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) func TestParseEndpoint(t *testing.T) { - endpoint := "example.com" - u, err := ParseEndpoint(endpoint) - if err != nil { - t.Fatalf("failed to parse endpoint %s: %v", endpoint, err) + cases := []struct { + input string + err bool + expected string + }{ + {" example.com/ ", false, "http://example.com"}, + {"ftp://example.com", true, ""}, + {"http://example.com", false, "http://example.com"}, + {"https://example.com", false, "https://example.com"}, + {"http://example!@#!?//#", true, ""}, } - if u.String() != "http://example.com" { - t.Errorf("unexpected endpoint: %s != %s", endpoint, "http://example.com") - } - - endpoint = "https://example.com" - u, err = ParseEndpoint(endpoint) - if err != nil { - t.Fatalf("failed to parse endpoint %s: %v", endpoint, err) - } - - if u.String() != "https://example.com" { - t.Errorf("unexpected endpoint: %s != %s", endpoint, "https://example.com") - } - - endpoint = " example.com/ " - u, err = ParseEndpoint(endpoint) - if err != nil { - t.Fatalf("failed to parse endpoint %s: %v", endpoint, err) - } - - if u.String() != "http://example.com" { - t.Errorf("unexpected endpoint: %s != %s", endpoint, "http://example.com") + for _, c := range cases { + u, err := ParseEndpoint(c.input) + if c.err { + require.NotNil(t, err) + continue + } + require.Nil(t, err) + assert.Equal(t, c.expected, u.String()) } } diff --git a/src/ui/api/email.go b/src/ui/api/email.go index 9dd3d67b8..66a666a46 100644 --- a/src/ui/api/email.go +++ b/src/ui/api/email.go @@ -106,7 +106,9 @@ func (e *EmailAPI) Ping() { addr := net.JoinHostPort(host, strconv.Itoa(port)) if err := email.Ping(addr, identity, username, password, pingEmailTimeout, ssl, insecure); err != nil { - log.Debugf("ping %s failed: %v", addr, err) - e.CustomAbort(http.StatusBadRequest, err.Error()) + log.Errorf("failed to ping email server: %v", err) + // do not return any detail information of the error, or may cause SSRF security issue #3755 + e.RenderError(http.StatusBadRequest, "failed to ping email server") + return } } diff --git a/src/ui/api/email_test.go b/src/ui/api/email_test.go index bf5d26111..154fb69e2 100644 --- a/src/ui/api/email_test.go +++ b/src/ui/api/email_test.go @@ -16,7 +16,6 @@ package api import ( "fmt" - "strings" "testing" "github.com/stretchr/testify/assert" @@ -36,7 +35,7 @@ func TestPingEmail(t *testing.T) { assert.Equal(401, code, "the status code of ping email server with non-admin user should be 401") - //case 2: bad request + //case 2: empty email host settings := `{ "email_host": "" }` @@ -47,7 +46,7 @@ func TestPingEmail(t *testing.T) { return } - assert.Equal(400, code, "the status code of ping email server should be 400") + assert.Equal(400, code) //case 3: secure connection with admin role settings = `{ @@ -58,18 +57,13 @@ func TestPingEmail(t *testing.T) { "email_ssl": true }` - code, body, err := apiTest.PingEmail(*admin, []byte(settings)) + code, _, err = apiTest.PingEmail(*admin, []byte(settings)) if err != nil { t.Errorf("failed to test ping email server: %v", err) return } - assert.Equal(400, code, "the status code of ping email server should be 400") - - if !strings.Contains(body, "535") { - t.Errorf("unexpected error: %s does not contains 535", body) - return - } + assert.Equal(400, code) //case 4: ping email server whose settings are read from config code, _, err = apiTest.PingEmail(*admin, nil) @@ -78,5 +72,5 @@ func TestPingEmail(t *testing.T) { return } - assert.Equal(400, code, "the status code of ping email server should be 400") + assert.Equal(400, code) } diff --git a/src/ui/api/ldap.go b/src/ui/api/ldap.go index f3f2005a5..42ffb058c 100644 --- a/src/ui/api/ldap.go +++ b/src/ui/api/ldap.go @@ -29,7 +29,7 @@ type LdapAPI struct { } const ( - pingErrorMessage = "LDAP connection test failed!" + pingErrorMessage = "LDAP connection test failed" loadSystemErrorMessage = "Can't load system configuration!" canNotOpenLdapSession = "Can't open LDAP session!" searchLdapFailMessage = "LDAP search failed!" @@ -72,6 +72,7 @@ func (l *LdapAPI) Ping() { if err != nil { log.Errorf("ldap connect fail, error: %v", err) + // do not return any detail information of the error, or may cause SSRF security issue #3755 l.RenderError(http.StatusBadRequest, pingErrorMessage) return } diff --git a/src/ui/api/target.go b/src/ui/api/target.go index 61f8939eb..603341bf0 100644 --- a/src/ui/api/target.go +++ b/src/ui/api/target.go @@ -16,15 +16,12 @@ package api import ( "fmt" - "net" "net/http" - "net/url" "strconv" "github.com/vmware/harbor/src/common/dao" "github.com/vmware/harbor/src/common/models" "github.com/vmware/harbor/src/common/utils" - registry_error "github.com/vmware/harbor/src/common/utils/error" "github.com/vmware/harbor/src/common/utils/log" "github.com/vmware/harbor/src/common/utils/registry" "github.com/vmware/harbor/src/common/utils/registry/auth" @@ -60,27 +57,15 @@ func (t *TargetAPI) Prepare() { func (t *TargetAPI) ping(endpoint, username, password string, insecure bool) { registry, err := newRegistryClient(endpoint, insecure, username, password) - if err != nil { - // timeout, dns resolve error, connection refused, etc. - if urlErr, ok := err.(*url.Error); ok { - if netErr, ok := urlErr.Err.(net.Error); ok { - t.CustomAbort(http.StatusBadRequest, netErr.Error()) - } - - t.CustomAbort(http.StatusBadRequest, urlErr.Error()) - } - - log.Errorf("failed to create registry client: %#v", err) - t.CustomAbort(http.StatusInternalServerError, http.StatusText(http.StatusInternalServerError)) + if err == nil { + err = registry.Ping() } - if err = registry.Ping(); err != nil { - if regErr, ok := err.(*registry_error.HTTPError); ok { - t.CustomAbort(regErr.StatusCode, regErr.Detail) - } - - log.Errorf("failed to ping registry %s: %v", registry.Endpoint.String(), err) - t.CustomAbort(http.StatusInternalServerError, http.StatusText(http.StatusInternalServerError)) + if err != nil { + log.Errorf("failed to ping target: %v", err) + // do not return any detail information of the error, or may cause SSRF security issue #3755 + t.RenderError(http.StatusBadRequest, "failed to ping target") + return } } @@ -117,7 +102,14 @@ func (t *TargetAPI) Ping() { } if req.Endpoint != nil { - target.URL = *req.Endpoint + url, err := utils.ParseEndpoint(*req.Endpoint) + if err != nil { + t.HandleBadRequest(err.Error()) + return + } + + // Prevent SSRF security issue #3755 + target.URL = url.Scheme + "://" + url.Host + url.Path } if req.Username != nil { target.Username = *req.Username @@ -129,11 +121,6 @@ func (t *TargetAPI) Ping() { target.Insecure = *req.Insecure } - if len(target.URL) == 0 { - t.HandleBadRequest("empty endpoint") - return - } - t.ping(target.URL, target.Username, target.Password, target.Insecure) } diff --git a/src/ui/auth/authenticator.go b/src/ui/auth/authenticator.go index 111f001aa..eb9da0d06 100644 --- a/src/ui/auth/authenticator.go +++ b/src/ui/auth/authenticator.go @@ -39,6 +39,34 @@ type AuthenticateHelper interface { OnBoardUser(u *models.User) error // Get user information from account repository SearchUser(username string) (*models.User, error) + // Update user information after authenticate, such as Onboard or sync info etc + PostAuthenticate(u *models.User) error +} + +// DefaultAuthenticateHelper - default AuthenticateHelper implementation +type DefaultAuthenticateHelper struct { +} + +// Authenticate ... +func (d *DefaultAuthenticateHelper) Authenticate(m models.AuthModel) (*models.User, error) { + return nil, nil +} + +// OnBoardUser will check if a user exists in user table, if not insert the user and +// put the id in the pointer of user model, if it does exist, fill in the user model based +// on the data record of the user +func (d *DefaultAuthenticateHelper) OnBoardUser(u *models.User) error { + return nil +} + +//SearchUser - Get user information from account repository +func (d *DefaultAuthenticateHelper) SearchUser(username string) (*models.User, error) { + return nil, nil +} + +//PostAuthenticate - Update user information after authenticate, such as Onboard or sync info etc +func (d *DefaultAuthenticateHelper) PostAuthenticate(u *models.User) error { + return nil } var registry = make(map[string]AuthenticateHelper) @@ -79,6 +107,9 @@ func Login(m models.AuthModel) (*models.User, error) { lock.Lock(m.Principal) time.Sleep(frozenTime) } + + authenticator.PostAuthenticate(user) + return user, err } @@ -112,3 +143,12 @@ func SearchUser(username string) (*models.User, error) { } return helper.SearchUser(username) } + +// PostAuthenticate - +func PostAuthenticate(u *models.User) error { + helper, err := getHelper() + if err != nil { + return err + } + return helper.PostAuthenticate(u) +} diff --git a/src/ui/auth/db/db.go b/src/ui/auth/db/db.go index c75f396bf..8cfee4c94 100644 --- a/src/ui/auth/db/db.go +++ b/src/ui/auth/db/db.go @@ -21,7 +21,9 @@ import ( ) // Auth implements Authenticator interface to authenticate user against DB. -type Auth struct{} +type Auth struct { + auth.DefaultAuthenticateHelper +} // Authenticate calls dao to authenticate user. func (d *Auth) Authenticate(m models.AuthModel) (*models.User, error) { @@ -32,12 +34,6 @@ func (d *Auth) Authenticate(m models.AuthModel) (*models.User, error) { return u, nil } -// OnBoardUser - Dummy implementation when auth_mod is db_auth -func (d *Auth) OnBoardUser(user *models.User) error { - //No need to create user in local database - return nil -} - // SearchUser - Check if user exist in local db func (d *Auth) SearchUser(username string) (*models.User, error) { var queryCondition = models.User{ diff --git a/src/ui/auth/ldap/ldap.go b/src/ui/auth/ldap/ldap.go index 885ef8824..8ca332a34 100644 --- a/src/ui/auth/ldap/ldap.go +++ b/src/ui/auth/ldap/ldap.go @@ -16,6 +16,7 @@ package ldap import ( "fmt" + "regexp" "strings" "github.com/vmware/harbor/src/common/dao" @@ -26,7 +27,9 @@ import ( ) // Auth implements AuthenticateHelper interface to authenticate against LDAP -type Auth struct{} +type Auth struct { + auth.DefaultAuthenticateHelper +} // Authenticate checks user's credential against LDAP based on basedn template and LDAP URL, // if the check is successful a dummy record will be inserted into DB, such that this user can @@ -68,7 +71,7 @@ func (l *Auth) Authenticate(m models.AuthModel) (*models.User, error) { u := models.User{} u.Username = ldapUsers[0].Username - u.Email = ldapUsers[0].Email + u.Email = strings.TrimSpace(ldapUsers[0].Email) u.Realname = ldapUsers[0].Realname dn := ldapUsers[0].DN @@ -78,34 +81,7 @@ func (l *Auth) Authenticate(m models.AuthModel) (*models.User, error) { log.Warningf("Failed to bind user, username: %s, dn: %s, error: %v", u.Username, dn, err) return nil, nil } - exist, err := dao.UserExists(u, "username") - if err != nil { - return nil, err - } - - if exist { - currentUser, err := dao.GetUser(u) - if err != nil { - return nil, err - } - u.UserID = currentUser.UserID - u.HasAdminRole = currentUser.HasAdminRole - } else { - var user models.User - user.Username = ldapUsers[0].Username - user.Email = ldapUsers[0].Email - user.Realname = ldapUsers[0].Realname - - err = auth.OnBoardUser(&user) - if err != nil || user.UserID <= 0 { - log.Errorf("Can't import user %s, error: %v", ldapUsers[0].Username, err) - return nil, fmt.Errorf("can't import user %s, error: %v", ldapUsers[0].Username, err) - } - u.UserID = user.UserID - } - return &u, nil - } // OnBoardUser will check if a user exists in user table, if not insert the user and @@ -153,6 +129,52 @@ func (l *Auth) SearchUser(username string) (*models.User, error) { return &user, nil } +//PostAuthenticate -- If user exist in harbor DB, sync email address, if not exist, call OnBoardUser +func (l *Auth) PostAuthenticate(u *models.User) error { + + exist, err := dao.UserExists(*u, "username") + if err != nil { + return err + } + + if exist { + queryCondition := models.User{ + Username: u.Username, + } + dbUser, err := dao.GetUser(queryCondition) + if err != nil { + return err + } + if dbUser == nil { + fmt.Printf("User not found in DB %+v", u) + return nil + } + u.UserID = dbUser.UserID + u.HasAdminRole = dbUser.HasAdminRole + + if dbUser.Email != u.Email { + Re := regexp.MustCompile(`^[a-z0-9._%+\-]+@[a-z0-9.\-]+\.[a-z]{2,4}$`) + if !Re.MatchString(u.Email) { + log.Debugf("Not a valid email address: %v, skip to sync", u.Email) + } else { + dao.ChangeUserProfile(*u, "Email") + } + u.Email = dbUser.Email + } + + return nil + } + + err = auth.OnBoardUser(u) + if err != nil { + return err + } + if u.UserID <= 0 { + return fmt.Errorf("Can not OnBoardUser %v", u) + } + return nil +} + func init() { auth.Register("ldap_auth", &Auth{}) } diff --git a/src/ui/auth/ldap/ldap_test.go b/src/ui/auth/ldap/ldap_test.go index 6163b6fe7..909e13d2b 100644 --- a/src/ui/auth/ldap/ldap_test.go +++ b/src/ui/auth/ldap/ldap_test.go @@ -19,6 +19,7 @@ import ( "os" "testing" + "github.com/stretchr/testify/assert" "github.com/vmware/harbor/src/common" "github.com/vmware/harbor/src/common/dao" "github.com/vmware/harbor/src/common/models" @@ -204,3 +205,59 @@ func TestAuthenticateHelperSearchUser(t *testing.T) { t.Error("Failed to search user test") } } + +func TestPostAuthentication(t *testing.T) { + + assert := assert.New(t) + user1 := &models.User{ + Username: "test003", + Email: "test003@vmware.com", + Realname: "test003", + } + + queryCondition := models.User{ + Username: "test003", + Realname: "test003", + } + + err := auth.OnBoardUser(user1) + assert.Nil(err) + + user2 := &models.User{ + Username: "test003", + Email: "234invalidmail@@@@@", + } + + auth.PostAuthenticate(user2) + + dbUser, err := dao.GetUser(queryCondition) + if err != nil { + t.Fatalf("Failed to get user, error %v", err) + } + assert.EqualValues("test003@vmware.com", dbUser.Email) + + user3 := &models.User{ + Username: "test003", + } + + auth.PostAuthenticate(user3) + dbUser, err = dao.GetUser(queryCondition) + if err != nil { + t.Fatalf("Failed to get user, error %v", err) + } + assert.EqualValues("test003@vmware.com", dbUser.Email) + + user4 := &models.User{ + Username: "test003", + Email: "test003@example.com", + } + + auth.PostAuthenticate(user4) + + dbUser, err = dao.GetUser(queryCondition) + if err != nil { + t.Fatalf("Failed to get user, error %v", err) + } + assert.EqualValues("test003@example.com", dbUser.Email) + dao.CleanUser(int64(dbUser.UserID)) +} diff --git a/src/ui/auth/uaa/uaa.go b/src/ui/auth/uaa/uaa.go index 22557a9bf..b357e0833 100644 --- a/src/ui/auth/uaa/uaa.go +++ b/src/ui/auth/uaa/uaa.go @@ -16,6 +16,7 @@ package uaa import ( "fmt" + "os" "strings" "sync" @@ -38,6 +39,7 @@ func CreateClient() (uaa.Client, error) { ClientSecret: UAASettings.ClientSecret, Endpoint: UAASettings.Endpoint, SkipTLSVerify: !UAASettings.VerifyCert, + CARootPath: os.Getenv("UAA_CA_ROOT"), } return uaa.NewDefaultClient(cfg) } @@ -46,6 +48,7 @@ func CreateClient() (uaa.Client, error) { type Auth struct { sync.Mutex client uaa.Client + auth.DefaultAuthenticateHelper } //Authenticate ... diff --git a/src/ui_ng/lib/package.json b/src/ui_ng/lib/package.json index cfc448ae9..e7c4f3397 100644 --- a/src/ui_ng/lib/package.json +++ b/src/ui_ng/lib/package.json @@ -1,6 +1,6 @@ { "name": "harbor-ui", - "version": "0.6.2", + "version": "0.6.6", "description": "Harbor shared UI components based on Clarity and Angular4", "scripts": { "start": "ng serve --host 0.0.0.0 --port 4500 --proxy-config proxy.config.json", diff --git a/src/ui_ng/lib/pkg/package.json b/src/ui_ng/lib/pkg/package.json index 3d406f77b..0853af114 100644 --- a/src/ui_ng/lib/pkg/package.json +++ b/src/ui_ng/lib/pkg/package.json @@ -1,6 +1,6 @@ { "name": "harbor-ui", - "version": "0.6.2", + "version": "0.6.6", "description": "Harbor shared UI components based on Clarity and Angular4", "author": "VMware", "module": "index.js", diff --git a/src/ui_ng/lib/src/confirmation-dialog/confirmation-dialog.component.html.ts b/src/ui_ng/lib/src/confirmation-dialog/confirmation-dialog.component.html.ts index e882ba5c6..086d008e0 100644 --- a/src/ui_ng/lib/src/confirmation-dialog/confirmation-dialog.component.html.ts +++ b/src/ui_ng/lib/src/confirmation-dialog/confirmation-dialog.component.html.ts @@ -23,7 +23,6 @@ export const CONFIRMATION_DIALOG_TEMPLATE: string = ` {{'BUTTON.CANCEL' | translate}} {{'BUTTON.CONFIRM' | translate}} - {{'BUTTON.CLOSE' | translate}} {{'BUTTON.NO' | translate}} @@ -31,7 +30,7 @@ export const CONFIRMATION_DIALOG_TEMPLATE: string = ` {{'BUTTON.CANCEL' | translate}} - {{'BUTTON.DELETE' | translate}} + {{'BUTTON.DELETE' | translate}} {{'BUTTON.CLOSE' | translate}} diff --git a/src/ui_ng/lib/src/confirmation-dialog/confirmation-dialog.component.ts b/src/ui_ng/lib/src/confirmation-dialog/confirmation-dialog.component.ts index 92d549a7c..9216d6356 100644 --- a/src/ui_ng/lib/src/confirmation-dialog/confirmation-dialog.component.ts +++ b/src/ui_ng/lib/src/confirmation-dialog/confirmation-dialog.component.ts @@ -98,8 +98,8 @@ export class ConfirmationDialogComponent { this.close(); } - confirm(): void { - if(!this.message){//Inproper condition + delete(): void { + if (!this.message){//Inproper condition this.close(); return; } @@ -118,4 +118,21 @@ export class ConfirmationDialogComponent { ); this.confirmAction.emit(message); } + + confirm(): void { + if (!this.message){//Inproper condition + this.close(); + return; + } + + let data: any = this.message.data ? this.message.data : {}; + let target = this.message.targetId ? this.message.targetId : ConfirmationTargets.EMPTY; + let message = new ConfirmationAcknowledgement( + ConfirmationState.CONFIRMED, + data, + target + ); + this.confirmAction.emit(message); + this.close(); + } } diff --git a/src/ui_ng/lib/src/harbor-library.module.ts b/src/ui_ng/lib/src/harbor-library.module.ts index dca5fb101..16bac5064 100644 --- a/src/ui_ng/lib/src/harbor-library.module.ts +++ b/src/ui_ng/lib/src/harbor-library.module.ts @@ -214,7 +214,7 @@ export class HarborLibraryModule { config.configService || { provide: ConfigurationService, useClass: ConfigurationDefaultService }, config.jobLogService || { provide: JobLogService, useClass: JobLogDefaultService }, config.projectPolicyService || { provide: ProjectService, useClass: ProjectDefaultService }, - //Do initializing + // Do initializing TranslateServiceInitializer, { provide: APP_INITIALIZER, diff --git a/src/ui_ng/lib/src/list-replication-rule/list-replication-rule.component.html.ts b/src/ui_ng/lib/src/list-replication-rule/list-replication-rule.component.html.ts index 6b263e84c..f62870012 100644 --- a/src/ui_ng/lib/src/list-replication-rule/list-replication-rule.component.html.ts +++ b/src/ui_ng/lib/src/list-replication-rule/list-replication-rule.component.html.ts @@ -1,40 +1,28 @@ export const LIST_REPLICATION_RULE_TEMPLATE: string = ` - + - - {{'REPLICATION.NEW_REPLICATION_RULE' | translate}} - {{'REPLICATION.EDIT_POLICY' | translate}} - {{'REPLICATION.DELETE_POLICY' | translate}} + + {{'REPLICATION.NEW_REPLICATION_RULE' | translate}} + {{'REPLICATION.EDIT_POLICY' | translate}} + {{'REPLICATION.DELETE_POLICY' | translate}} + {{'REPLICATION.REPLICATE' | translate}} {{'REPLICATION.NAME' | translate}} - {{'REPLICATION.PROJECT' | translate}} + {{'REPLICATION.PROJECT' | translate}} {{'REPLICATION.DESCRIPTION' | translate}} - {{'REPLICATION.DESTINATION_NAME' | translate}} - {{'REPLICATION.LAST_START_TIME' | translate}} - {{'REPLICATION.ACTIVATION' | translate}} + {{'REPLICATION.DESTINATION_NAME' | translate}} + {{'REPLICATION.SCHEDULE' | translate}} {{'REPLICATION.PLACEHOLDER' | translate }} - - - - {{p.name}} - - - {{p.name}} - + {{p.name}} + + {{p.projects?.length>0 ? p.projects[0].name : ''}} - {{p.project_name}} {{p.description ? p.description : '-'}} - {{p.target_name}} - - - - {{p.start_time | date: 'short'}} - - - {{ (p.enabled === 1 ? 'REPLICATION.ENABLED' : 'REPLICATION.DISABLED') | translate}} - + {{p.targets?.length>0 ? p.targets[0].name : ''}} + {{p.trigger ? p.trigger.kind : ''}} {{pagination.firstItem + 1}} - {{pagination.lastItem +1 }} {{'REPLICATION.OF' | translate}} {{pagination.totalItems }} {{'REPLICATION.ITEMS' | translate}} diff --git a/src/ui_ng/lib/src/list-replication-rule/list-replication-rule.component.ts b/src/ui_ng/lib/src/list-replication-rule/list-replication-rule.component.ts index b76bc3982..96bc4f98d 100644 --- a/src/ui_ng/lib/src/list-replication-rule/list-replication-rule.component.ts +++ b/src/ui_ng/lib/src/list-replication-rule/list-replication-rule.component.ts @@ -67,6 +67,7 @@ export class ListReplicationRuleComponent implements OnInit, OnChanges { @Output() toggleOne = new EventEmitter(); @Output() redirect = new EventEmitter(); @Output() openNewRule = new EventEmitter(); + @Output() replicateManual = new EventEmitter(); projectScope: boolean = false; @@ -95,8 +96,8 @@ export class ListReplicationRuleComponent implements OnInit, OnChanges { setInterval(() => ref.markForCheck(), 500); } - public get creationAvailable(): boolean { - return !this.readonly && this.projectId ? true : false; + public get opereateAvailable(): boolean { + return !this.readonly && !this.projectId ? true : false; } @@ -221,6 +222,10 @@ export class ListReplicationRuleComponent implements OnInit, OnChanges { this.editOne.emit(rules); } + replicateRule(rule: ReplicationRule) { + this.replicateManual.emit(rule); + } + toggleRule(rule: ReplicationRule) { let toggleConfirmMessage: ConfirmationMessage = new ConfirmationMessage( rule.enabled === 1 ? 'REPLICATION.TOGGLE_DISABLE_TITLE' : 'REPLICATION.TOGGLE_ENABLE_TITLE', diff --git a/src/ui_ng/lib/src/replication/replication.component.css.ts b/src/ui_ng/lib/src/replication/replication.component.css.ts index 473cfc00e..51f755820 100644 --- a/src/ui_ng/lib/src/replication/replication.component.css.ts +++ b/src/ui_ng/lib/src/replication/replication.component.css.ts @@ -21,4 +21,11 @@ export const REPLICATION_STYLE: string = ` .option-right-down { padding-right: 16px; margin-top: 24px; +} + .rightPos{ + position: absolute; + right: 35px; + margin-top: 5px; + z-index: 100; + height: 32px; }`; \ No newline at end of file diff --git a/src/ui_ng/lib/src/replication/replication.component.html.ts b/src/ui_ng/lib/src/replication/replication.component.html.ts index be054f729..e5f7bb9e1 100644 --- a/src/ui_ng/lib/src/replication/replication.component.html.ts +++ b/src/ui_ng/lib/src/replication/replication.component.html.ts @@ -1,13 +1,8 @@ export const REPLICATION_TEMPLATE: string = ` - - - + + + - - - {{r.description | translate}} - - @@ -16,8 +11,9 @@ export const REPLICATION_TEMPLATE: string = ` - + + {{'REPLICATION.REPLICATION_JOBS' | translate}} @@ -72,5 +68,4 @@ export const REPLICATION_TEMPLATE: string = ` - `; \ No newline at end of file diff --git a/src/ui_ng/lib/src/replication/replication.component.spec.ts b/src/ui_ng/lib/src/replication/replication.component.spec.ts index ca875e0ac..7b720ec15 100644 --- a/src/ui_ng/lib/src/replication/replication.component.spec.ts +++ b/src/ui_ng/lib/src/replication/replication.component.spec.ts @@ -260,19 +260,6 @@ describe('Replication Component (inline template)', ()=>{ }); })); - it('Should filter replication rules by status', async(()=>{ - fixture.detectChanges(); - fixture.whenStable().then(()=>{ - fixture.detectChanges(); - comp.doFilterRuleStatus('1' /*Enabled*/); - fixture.detectChanges(); - let el: HTMLElement = deRules.nativeElement; - fixture.detectChanges(); - expect(el).toBeTruthy(); - expect(el.textContent.trim()).toEqual('sync_02'); - }); - })); - it('Should filter replication jobs by keywords', async(()=>{ fixture.detectChanges(); fixture.whenStable().then(()=>{ diff --git a/src/ui_ng/lib/src/replication/replication.component.ts b/src/ui_ng/lib/src/replication/replication.component.ts index 9e6ce673f..96a52d7e3 100644 --- a/src/ui_ng/lib/src/replication/replication.component.ts +++ b/src/ui_ng/lib/src/replication/replication.component.ts @@ -88,6 +88,8 @@ export class ReplicationComponent implements OnInit, OnDestroy { @Input() readonly: boolean; @Output() redirect = new EventEmitter(); + @Output() openCreateRule = new EventEmitter(); + @Output() openEdit = new EventEmitter(); search: SearchOption = new SearchOption(); @@ -111,8 +113,8 @@ export class ReplicationComponent implements OnInit, OnDestroy { @ViewChild(ListReplicationRuleComponent) listReplicationRule: ListReplicationRuleComponent; - @ViewChild(CreateEditRuleComponent) - createEditPolicyComponent: CreateEditRuleComponent; +/* @ViewChild(CreateEditRuleComponent) + createEditPolicyComponent: CreateEditRuleComponent;*/ @ViewChild("replicationLogViewer") replicationLogViewer: JobLogViewerComponent; @@ -134,9 +136,6 @@ export class ReplicationComponent implements OnInit, OnDestroy { private translateService: TranslateService) { } - public get creationAvailable(): boolean { - return !this.readonly && this.projectId ? true : false; - } public get showPaginationIndex(): boolean { return this.totalCount > 0; @@ -155,7 +154,7 @@ export class ReplicationComponent implements OnInit, OnDestroy { } openModal(): void { - this.createEditPolicyComponent.openCreateEditRule(true); + this.openCreateRule.emit(); } openEditRule(rule: ReplicationRule) { @@ -164,7 +163,7 @@ export class ReplicationComponent implements OnInit, OnDestroy { if (rule.enabled === 1) { editable = false; } - this.createEditPolicyComponent.openCreateEditRule(editable, rule.id); + this.openEdit.emit(rule.id); } } @@ -260,6 +259,14 @@ export class ReplicationComponent implements OnInit, OnDestroy { } } + replicateManualRule(rule: ReplicationRule): void { + toPromise(this.replicationService.replicateRule(rule.id)) + .then(response => { + this.refreshJobs(); + }) + .catch(error => this.errorHandler.error(error)); + } + customRedirect(rule: ReplicationRule) { this.redirect.emit(rule); } @@ -269,14 +276,6 @@ export class ReplicationComponent implements OnInit, OnDestroy { this.listReplicationRule.retrieveRules(ruleName); } - doFilterRuleStatus($event: any) { - if ($event && $event.target && $event.target["value"]) { - let status = $event.target["value"]; - this.currentRuleStatus = this.ruleStatus.find((r: any) => r.key === status); - this.listReplicationRule.filterRuleStatus(this.currentRuleStatus.key); - } - } - doFilterJobStatus($event: any) { if ($event && $event.target && $event.target["value"]) { let status = $event.target["value"]; diff --git a/src/ui_ng/lib/src/repository/repository.component.css.ts b/src/ui_ng/lib/src/repository/repository.component.css.ts index 7e4812f15..0b1f34ccf 100644 --- a/src/ui_ng/lib/src/repository/repository.component.css.ts +++ b/src/ui_ng/lib/src/repository/repository.component.css.ts @@ -26,8 +26,25 @@ export const REPOSITORY_STYLE = `.option-right { font-size: 32px; } -pre { - white-space: pre-wrap; +.no-info-div { + background: white; + border: 1px; + border-style: solid; + border-color: #CCCCCC; + padding: 12px 12px 12px 12px; +} + +.info-div { + background: white; + border: 1px; + border-style: solid; + border-color: #CCCCCC; + padding: 0px 12px 24px 12px; +} + +.info-pre { + border: 0px; + max-height: fit-content; } #info-edit-button { diff --git a/src/ui_ng/lib/src/repository/repository.component.html.ts b/src/ui_ng/lib/src/repository/repository.component.html.ts index a2d021aab..2bb443602 100644 --- a/src/ui_ng/lib/src/repository/repository.component.html.ts +++ b/src/ui_ng/lib/src/repository/repository.component.html.ts @@ -24,12 +24,18 @@ export const REPOSITORY_TEMPLATE = ` - {{'BUTTON.EDIT' | translate}} + {{'BUTTON.EDIT' | translate}} - - {{'REPOSITORY.NO_INFO' | translate }} - {{ imageInfo }} - + + + {{'REPOSITORY.NO_INFO' | translate }} + + + {{ imageInfo }} + + + + {{'BUTTON.SAVE' | translate}} diff --git a/src/ui_ng/lib/src/service/replication.service.ts b/src/ui_ng/lib/src/service/replication.service.ts index 9d722798c..c0f3bce2a 100644 --- a/src/ui_ng/lib/src/service/replication.service.ts +++ b/src/ui_ng/lib/src/service/replication.service.ts @@ -97,6 +97,9 @@ export abstract class ReplicationService { */ abstract disableReplicationRule(ruleId: number | string): Observable | Promise | any; + + abstract replicateRule(ruleId: number | string): Observable | Promise | any; + /** * Get the jobs for the specified replication rule. * Set query parameters through 'queryParams', support: @@ -137,6 +140,7 @@ export abstract class ReplicationService { export class ReplicationDefaultService extends ReplicationService { _ruleBaseUrl: string; _jobBaseUrl: string; + _replicateUrl: string; constructor( private http: Http, @@ -147,6 +151,7 @@ export class ReplicationDefaultService extends ReplicationService { config.replicationRuleEndpoint : '/api/policies/replication'; this._jobBaseUrl = config.replicationJobEndpoint ? config.replicationJobEndpoint : '/api/jobs/replication'; + this._replicateUrl = '/api/replications'; } //Private methods @@ -216,6 +221,17 @@ export class ReplicationDefaultService extends ReplicationService { .catch(error => Promise.reject(error)); } + public replicateRule(ruleId: number | string): Observable | Promise | any { + if (!ruleId) { + return Promise.reject("Bad argument"); + } + + let url: string = `${this._replicateUrl}`; + return this.http.post(url, {policy_id: ruleId}, HTTP_JSON_OPTIONS).toPromise() + .then(response => response) + .catch(error => Promise.reject(error)); + } + public enableReplicationRule(ruleId: number | string, enablement: number): Observable | Promise | any { if (!ruleId || ruleId <= 0) { return Promise.reject('Bad argument'); diff --git a/src/ui_ng/lib/src/shared/shared.module.ts b/src/ui_ng/lib/src/shared/shared.module.ts index 95a1bd85c..365b14ee9 100644 --- a/src/ui_ng/lib/src/shared/shared.module.ts +++ b/src/ui_ng/lib/src/shared/shared.module.ts @@ -3,7 +3,7 @@ import { CommonModule } from '@angular/common'; import { HttpModule, Http } from '@angular/http'; import { ClarityModule } from 'clarity-angular'; import { FormsModule } from '@angular/forms'; -import { TranslateModule, TranslateLoader, TranslateService, MissingTranslationHandler } from "@ngx-translate/core"; +import { TranslateModule, TranslateLoader, TranslateService, MissingTranslationHandler } from '@ngx-translate/core'; import { MyMissingTranslationHandler } from '../i18n/missing-trans.handler'; import { TranslateHttpLoader } from '@ngx-translate/http-loader'; import { TranslatorJsonLoader } from '../i18n/local-json.loader'; @@ -30,9 +30,9 @@ export function GeneralTranslatorLoader(http: Http, config: IServiceConfig) { } /** - * + * * Module for sharing common modules - * + * * @export * @class SharedModule */ @@ -68,4 +68,4 @@ export function GeneralTranslatorLoader(http: Http, config: IServiceConfig) { providers: [CookieService] }) -export class SharedModule { } \ No newline at end of file +export class SharedModule { } diff --git a/src/ui_ng/package.json b/src/ui_ng/package.json index 7b608e743..7ee2c24de 100644 --- a/src/ui_ng/package.json +++ b/src/ui_ng/package.json @@ -1,6 +1,6 @@ { "name": "harbor", - "version": "1.2.0", + "version": "1.3.0", "description": "Harbor UI with Clarity", "angular-cli": {}, "scripts": { @@ -31,7 +31,7 @@ "clarity-icons": "^0.10.17", "clarity-ui": "^0.10.17", "core-js": "^2.4.1", - "harbor-ui": "0.6.5", + "harbor-ui": "0.6.9", "intl": "^1.2.5", "mutationobserver-shim": "^0.3.2", "ngx-cookie": "^1.0.0", diff --git a/src/ui_ng/rollup-config.js b/src/ui_ng/rollup-config.js index 36cedf305..bb5ba936f 100644 --- a/src/ui_ng/rollup-config.js +++ b/src/ui_ng/rollup-config.js @@ -21,7 +21,7 @@ export default { plugins: [ nodeResolve({jsnext: true, module: true, browser: true}), commonjs({ - include: ['node_modules/**'] + include: ['node_modules/**'], }), uglify() ] diff --git a/src/ui_ng/src/app/account/sign-in/sign-in.component.css b/src/ui_ng/src/app/account/sign-in/sign-in.component.css index f9251f47c..ed6572137 100644 --- a/src/ui_ng/src/app/account/sign-in/sign-in.component.css +++ b/src/ui_ng/src/app/account/sign-in/sign-in.component.css @@ -47,4 +47,5 @@ font-size: 14px !important; position: relative; top: -9px; -} \ No newline at end of file +} +.bg{position: absolute;top: 60px; right: 0px;width: 100%; height: 100%; background-size: cover;} \ No newline at end of file diff --git a/src/ui_ng/src/app/account/sign-in/sign-in.component.html b/src/ui_ng/src/app/account/sign-in/sign-in.component.html index e19d06e01..445233195 100644 --- a/src/ui_ng/src/app/account/sign-in/sign-in.component.html +++ b/src/ui_ng/src/app/account/sign-in/sign-in.component.html @@ -1,4 +1,5 @@ - + + {{customAppTitle? customAppTitle:(appTitle | translate)}}™ diff --git a/src/ui_ng/src/app/base/global-search/global-search.component.ts b/src/ui_ng/src/app/base/global-search/global-search.component.ts index 037ae4a2e..7dc679921 100644 --- a/src/ui_ng/src/app/base/global-search/global-search.component.ts +++ b/src/ui_ng/src/app/base/global-search/global-search.component.ts @@ -57,7 +57,7 @@ export class GlobalSearchComponent implements OnInit, OnDestroy { ngOnInit(): void { //custom skin - let customSkinObj = this.skinableConfig.getProjects(); + let customSkinObj = this.skinableConfig.getProject(); if (customSkinObj && customSkinObj.projectName) { this.translate.get('GLOBAL_SEARCH.PLACEHOLDER', {'param': customSkinObj.projectName}).subscribe(res => { //Placeholder text diff --git a/src/ui_ng/src/app/base/harbor-shell/harbor-shell.component.html b/src/ui_ng/src/app/base/harbor-shell/harbor-shell.component.html index efe767bf4..b47b68883 100644 --- a/src/ui_ng/src/app/base/harbor-shell/harbor-shell.component.html +++ b/src/ui_ng/src/app/base/harbor-shell/harbor-shell.component.html @@ -8,21 +8,47 @@ - - - {{'SIDE_NAV.PROJECTS' | translate}} - {{'SIDE_NAV.LOGS' | translate}} - - - {{'SIDE_NAV.SYSTEM_MGMT.NAME' | translate}} - - {{'SIDE_NAV.SYSTEM_MGMT.USER' | translate}} - {{'SIDE_NAV.SYSTEM_MGMT.REPLICATION' | translate}} - {{'SIDE_NAV.SYSTEM_MGMT.CONFIG' | translate}} - - - - + + + + {{'SIDE_NAV.PROJECTS' | translate}} + + + + {{'SIDE_NAV.LOGS' | translate}} + + + + {{'SIDE_NAV.SYSTEM_MGMT.NAME' | translate}} + + + + + {{'SIDE_NAV.SYSTEM_MGMT.USER' | translate}} + + + + {{'SIDE_NAV.SYSTEM_MGMT.REGISTRY' | translate}} + + + + {{'SIDE_NAV.SYSTEM_MGMT.REPLICATION' | translate}} + + + + {{'SIDE_NAV.SYSTEM_MGMT.CONFIG' | translate}} + + + + diff --git a/src/ui_ng/src/app/base/navigator/navigator.component.html b/src/ui_ng/src/app/base/navigator/navigator.component.html index 42731159c..2452c61ec 100644 --- a/src/ui_ng/src/app/base/navigator/navigator.component.html +++ b/src/ui_ng/src/app/base/navigator/navigator.component.html @@ -2,7 +2,7 @@ - + {{customProjectName?.projectName? customProjectName?.projectName:(appTitle | translate)}} diff --git a/src/ui_ng/src/app/harbor-routing.module.ts b/src/ui_ng/src/app/harbor-routing.module.ts index fa01bf976..6ff812f29 100644 --- a/src/ui_ng/src/app/harbor-routing.module.ts +++ b/src/ui_ng/src/app/harbor-routing.module.ts @@ -50,6 +50,8 @@ import { LeavingConfigRouteDeactivate } from './shared/route/leaving-config-deac import { MemberGuard } from './shared/route/member-guard-activate.service'; import { TagDetailPageComponent } from './repository/tag-detail/tag-detail-page.component'; +import { ReplicationRuleComponent} from "./replication/replication-rule/replication-rule.component"; +import {LeavingNewRuleRouteDeactivate} from "./shared/route/leaving-new-rule-deactivate.service"; const harborRoutes: Routes = [ { path: '', redirectTo: 'harbor', pathMatch: 'full' }, @@ -78,25 +80,30 @@ const harborRoutes: Routes = [ component: UserComponent, canActivate: [SystemAdminGuard] }, + { + path: 'registries', + component: DestinationPageComponent, + canActivate: [SystemAdminGuard] + }, { path: 'replications', - component: ReplicationManagementComponent, + component: TotalReplicationPageComponent, canActivate: [SystemAdminGuard], canActivateChild: [SystemAdminGuard], - children: [ - { - path: 'rules', - component: TotalReplicationPageComponent - }, - { - path: 'endpoints', - component: DestinationPageComponent - }, - { - path: '**', - redirectTo: 'endpoints' - } - ] + }, + { + path: 'replications/:id/rule', + component: ReplicationRuleComponent, + canActivate: [SystemAdminGuard], + canActivateChild: [SystemAdminGuard], + canDeactivate: [LeavingNewRuleRouteDeactivate] + }, + { + path: 'replications/new-rule', + component: ReplicationRuleComponent, + canActivate: [SystemAdminGuard], + canActivateChild: [SystemAdminGuard], + canDeactivate: [LeavingNewRuleRouteDeactivate] }, { path: 'tags/:id/:repo', @@ -137,7 +144,6 @@ const harborRoutes: Routes = [ { path: 'replications', component: ReplicationPageComponent, - canActivate: [SystemAdminGuard] }, { path: 'members', @@ -158,6 +164,12 @@ const harborRoutes: Routes = [ component: ConfigurationComponent, canActivate: [SystemAdminGuard], canDeactivate: [LeavingConfigRouteDeactivate] + }, + { + path: 'registry', + component: DestinationPageComponent, + canActivate: [SystemAdminGuard], + canActivateChild: [SystemAdminGuard], } ] }, diff --git a/src/ui_ng/src/app/project/project-detail/project-detail.component.html b/src/ui_ng/src/app/project/project-detail/project-detail.component.html index 3feaa5d1c..9f6cf0939 100644 --- a/src/ui_ng/src/app/project/project-detail/project-detail.component.html +++ b/src/ui_ng/src/app/project/project-detail/project-detail.component.html @@ -13,7 +13,7 @@ {{'PROJECT_DETAIL.LOGS' | translate}} - + {{'PROJECT_DETAIL.REPLICATION' | translate}} diff --git a/src/ui_ng/src/app/project/project-detail/project-detail.component.ts b/src/ui_ng/src/app/project/project-detail/project-detail.component.ts index 942aedcb0..07f29774e 100644 --- a/src/ui_ng/src/app/project/project-detail/project-detail.component.ts +++ b/src/ui_ng/src/app/project/project-detail/project-detail.component.ts @@ -50,7 +50,11 @@ export class ProjectDetailComponent { public get isSystemAdmin(): boolean { let account = this.sessionService.getCurrentUser(); - return account != null && account.has_admin_role > 0; + return account && account.has_admin_role > 0; + } + + public get isSProjectAdmin(): boolean { + return this.currentProject.has_project_admin_role; } public get isSessionValid(): boolean { diff --git a/src/ui_ng/src/app/replication/destination/destination-page.component.html b/src/ui_ng/src/app/replication/destination/destination-page.component.html index 3d0c138d2..a1a1ea825 100644 --- a/src/ui_ng/src/app/replication/destination/destination-page.component.html +++ b/src/ui_ng/src/app/replication/destination/destination-page.component.html @@ -1,3 +1,4 @@ +{{'SIDE_NAV.SYSTEM_MGMT.REGISTRY' | translate}} \ No newline at end of file diff --git a/src/ui_ng/src/app/replication/replication-management/replication-management.component.ts b/src/ui_ng/src/app/replication/replication-management/replication-management.component.ts index a81236c8b..1996213d7 100644 --- a/src/ui_ng/src/app/replication/replication-management/replication-management.component.ts +++ b/src/ui_ng/src/app/replication/replication-management/replication-management.component.ts @@ -11,6 +11,10 @@ // 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. + +// This Module is used as Container For Endpoint and Replication Rules +// Will deprecated on Harbor 1.4.0 + import { Component } from '@angular/core'; @Component({ diff --git a/src/ui_ng/src/app/replication/replication-page.component.html b/src/ui_ng/src/app/replication/replication-page.component.html index 667be81c6..b2ebb01ba 100644 --- a/src/ui_ng/src/app/replication/replication-page.component.html +++ b/src/ui_ng/src/app/replication/replication-page.component.html @@ -1,3 +1,3 @@ - + \ No newline at end of file diff --git a/src/ui_ng/src/app/replication/replication-rule/list-project-model/list-project-model.component.css b/src/ui_ng/src/app/replication/replication-rule/list-project-model/list-project-model.component.css new file mode 100644 index 000000000..ab5ec9250 --- /dev/null +++ b/src/ui_ng/src/app/replication/replication-rule/list-project-model/list-project-model.component.css @@ -0,0 +1,5 @@ +.datagrid .datagrid-head{border: 0;} +.option-right{ position: absolute; right: 30px; top: 55px;} +:host >>> .datagrid-head{height: 0;border-width: 0;} +:host >>> .datagrid-scroll-wrapper .datagrid{margin-top: 0;} +.modal-body{height: 30em; overflow-y: auto; margin-top: 20px;} diff --git a/src/ui_ng/src/app/replication/replication-rule/list-project-model/list-project-model.component.html b/src/ui_ng/src/app/replication/replication-rule/list-project-model/list-project-model.component.html new file mode 100644 index 000000000..f4642b90f --- /dev/null +++ b/src/ui_ng/src/app/replication/replication-rule/list-project-model/list-project-model.component.html @@ -0,0 +1,33 @@ + + {{'PROJECT.ALL_PROJECTS' | translate}} + + + + + + {{projectTypes[0] | translate}} + {{projectTypes[1] | translate}} + {{projectTypes[2] | translate}} + + + + + + + + + + + {{project.name}} + + + {{pagination.firstItem + 1}} - {{pagination.lastItem +1 }} {{'PROJECT.OF' | translate}} {{pagination.totalItems }} {{'PROJECT.ITEMS' | translate}} + + + + + + diff --git a/src/ui_ng/src/app/replication/replication-rule/list-project-model/list-project-model.component.ts b/src/ui_ng/src/app/replication/replication-rule/list-project-model/list-project-model.component.ts new file mode 100644 index 000000000..a858b49c3 --- /dev/null +++ b/src/ui_ng/src/app/replication/replication-rule/list-project-model/list-project-model.component.ts @@ -0,0 +1,180 @@ +// Copyright (c) 2017 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. +import { + Component, + Output, + Input, + ChangeDetectionStrategy, + ChangeDetectorRef, + OnDestroy, EventEmitter +} from '@angular/core'; +import { Router, NavigationExtras } from '@angular/router'; + +import { SessionService } from '../../../shared/session.service'; +import { SearchTriggerService } from '../../../base/global-search/search-trigger.service'; +import { ProjectTypes, RoleInfo} from '../../../shared/shared.const'; +import { CustomComparator, doFiltering, doSorting, calculatePage } from '../../../shared/shared.utils'; + +import { Comparator, State } from 'clarity-angular'; +import { MessageHandlerService } from '../../../shared/message-handler/message-handler.service'; +import { StatisticHandler } from '../../../shared/statictics/statistic-handler.service'; +import { Subscription } from 'rxjs/Subscription'; +import { ConfirmationDialogService } from '../../../shared/confirmation-dialog/confirmation-dialog.service'; +import { ConfirmationMessage } from '../../../shared/confirmation-dialog/confirmation-message'; +import { ConfirmationTargets, ConfirmationState, ConfirmationButtons } from '../../../shared/shared.const'; +import {ProjectService} from "../../../project/project.service"; +import {Project} from "../../../project/project"; + +@Component({ + selector: 'list-project-model', + templateUrl: 'list-project-model.component.html', + styleUrls: ['list-project-model.component.css'], + changeDetection: ChangeDetectionStrategy.OnPush +}) +export class ListProjectModelComponent { + projectTypes = ProjectTypes; + loading: boolean = true; + projects: Project[] = []; + filteredType: number = 0;//All projects + searchKeyword: string = ""; + ismodelOpen: boolean ; + currentFilteredType: number = 0;//all projects + projectName: string = ""; + selectedProject: Project; + + roleInfo = RoleInfo; + repoCountComparator: Comparator = new CustomComparator("repo_count", "number"); + timeComparator: Comparator = new CustomComparator("creation_time", "date"); + accessLevelComparator: Comparator = new CustomComparator("public", "number"); + roleComparator: Comparator = new CustomComparator("current_user_role_id", "number"); + currentPage: number = 1; + totalCount: number = 0; + pageSize: number = 10; + currentState: State; + @Output() selectedPro = new EventEmitter(); + + constructor( + private session: SessionService, + private router: Router, + private searchTrigger: SearchTriggerService, + private proService: ProjectService, + private msgHandler: MessageHandlerService, + private statisticHandler: StatisticHandler, + private deletionDialogService: ConfirmationDialogService, + private ref: ChangeDetectorRef) { + } + + get selecteType(): number { + return this.currentFilteredType; + } + set selecteType(_project: number) { + this.currentFilteredType = _project; + if (window.sessionStorage) { + window.sessionStorage['projectTypeValue'] = _project; + } + } + + + clrLoad(state: State) { + //Keep state for future filtering and sorting + this.currentState = state; + + let pageNumber: number = calculatePage(state); + if (pageNumber <= 0) { pageNumber = 1; } + + this.loading = true; + + let passInFilteredType: number = undefined; + if (this.filteredType > 0) { + passInFilteredType = this.filteredType - 1; + } + this.proService.listProjects(this.searchKeyword, passInFilteredType, pageNumber, this.pageSize).toPromise() + .then(response => { + //Get total count + if (response.headers) { + let xHeader: string = response.headers.get("X-Total-Count"); + if (xHeader) { + this.totalCount = parseInt(xHeader, 0); + } + } + + this.projects = response.json() as Project[]; + //Do customising filtering and sorting + this.projects = doFiltering(this.projects, state); + this.projects = doSorting(this.projects, state); + + this.loading = false; + }) + .catch(error => { + this.loading = false; + this.msgHandler.handleError(error); + }); + + //Force refresh view + let hnd = setInterval(() => this.ref.markForCheck(), 100); + setTimeout(() => clearInterval(hnd), 3000); + } + + openModel(): void { + this.selectedProject = null; + this.ismodelOpen = true; + //Force refresh view + let hnd = setInterval(() => this.ref.markForCheck(), 100); + setTimeout(() => clearInterval(hnd), 2000); + } + + refresh(): void { + this.currentPage = 1; + this.filteredType = 0; + this.searchKeyword = ''; + + this.reload(); + } + + doFilterProject(): void { + this.currentPage = 1; + this.filteredType = this.selecteType; + this.reload(); + } + + doSearchProject(proName: string): void { + this.projectName = proName; + this.currentPage = 1; + this.searchKeyword = proName; + this.reload(); + } + + reload(): void { + let st: State = this.currentState; + if (!st) { + st = { + page: {} + }; + } + st.page.from = 0; + st.page.to = this.pageSize - 1; + st.page.size = this.pageSize; + + this.clrLoad(st); + } + + oKModel() { + this.ismodelOpen = false; + this.selectedPro.emit(this.selectedProject); + } + + closeModel(): void { + this.ismodelOpen = false; + } +} diff --git a/src/ui_ng/src/app/replication/replication-rule/replication-rule.component.ts b/src/ui_ng/src/app/replication/replication-rule/replication-rule.component.ts new file mode 100644 index 000000000..3a1f76a4d --- /dev/null +++ b/src/ui_ng/src/app/replication/replication-rule/replication-rule.component.ts @@ -0,0 +1,531 @@ +import {Component, OnInit, OnDestroy, ViewChild, ChangeDetectorRef, AfterViewInit} from '@angular/core'; +import {ProjectService} from '../../project/project.service'; +import {Project} from '../../project/project'; +import {ActivatedRoute, Router} from '@angular/router'; +import {FormArray, FormBuilder, FormGroup, Validators} from "@angular/forms"; +import {ReplicationRuleServie} from "./replication-rule.service"; +import {MessageHandlerService} from "../../shared/message-handler/message-handler.service"; +import {Target, Filter, ReplicationRule} from "./replication-rule"; +import {ConfirmationDialogService} from "../../shared/confirmation-dialog/confirmation-dialog.service"; +import { ConfirmationTargets, ConfirmationState } from '../../shared/shared.const'; +import {Subscription} from "rxjs/Subscription"; +import {ConfirmationMessage} from "../../shared/confirmation-dialog/confirmation-message"; +import {Subject} from "rxjs/Subject"; +import {ListProjectModelComponent} from "./list-project-model/list-project-model.component"; +import {toPromise, isEmptyObject, compareValue} from "harbor-ui/src/utils"; + + +const ONE_HOUR_SECONDS: number = 3600; +const ONE_DAY_SECONDS: number = 24 * ONE_HOUR_SECONDS; + +@Component ({ + selector: 'repliction-rule', + templateUrl: 'replication-rule.html', + styleUrls: ['replication-rule.css'] + +}) +export class ReplicationRuleComponent implements OnInit, AfterViewInit, OnDestroy { + _localTime: Date = new Date(); + policyId: number; + targetList: Target[] = []; + isFilterHide: boolean = false; + weeklySchedule: boolean; + isScheduleOpt: boolean; + isImmediate: boolean = true; + filterCount: number = 0; + selectedprojectList: Project[] = []; + triggerNames: string[] = ['immediate', 'schedule', 'manual']; + scheduleNames: string[] = ['daily', 'weekly']; + weekly: string[] = ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday']; + filterSelect: string[] = ['repository', 'tag']; + ruleNameTooltip: string = 'TOOLTIP.EMPTY'; + headerTitle: string = 'REPLICATION.ADD_POLICY'; + + filterListData: {[key: string]: any}[] = []; + inProgress: boolean = false; + inNameChecking: boolean = false; + isRuleNameExist: boolean = false; + nameChecker: Subject = new Subject(); + + confirmSub: Subscription; + ruleForm: FormGroup; + copyUpdateForm: ReplicationRule; + + @ViewChild(ListProjectModelComponent) + projectListModel: ListProjectModelComponent; + + baseFilterData(name: string, option: string[], state: boolean) { + return { + name: name, + options: option, + state: state, + isValid: true + }; + } + + constructor(public projectService: ProjectService, + private router: Router, + private fb: FormBuilder, + private repService: ReplicationRuleServie, + private route: ActivatedRoute, + private msgHandler: MessageHandlerService, + private confirmService: ConfirmationDialogService, + public ref: ChangeDetectorRef) { + this.createForm(); + + Promise.all([this.repService.getEndpoints(), this.repService.listProjects()]) + .then(res => { + if (!res[0] || !res[1]) { + this.msgHandler.error('REPLICATION.BACKINFO'); + setTimeout(() => { + this.router.navigate(['/harbor/replications']); + }, 2000); + }; + if (res[0] && res[1]) { + this.targetList = res[0]; + if (!this.policyId) { + this.setTarget([res[0][0]]); + this.setProject([res[1][0]]); + this.copyUpdateForm = Object.assign({}, this.ruleForm.value); + } + } + }); + } + + ngOnInit(): void { + this.policyId = +this.route.snapshot.params['id']; + if (this.policyId) { + this.headerTitle = 'REPLICATION.EDIT_POLICY_TITLE'; + this.repService.getReplicationRule(this.policyId) + .then((response) => { + this.copyUpdateForm = Object.assign({}, response); + // set filter value is [] if callback fiter value is null. + this.copyUpdateForm.filters = response.filters ? response.filters : []; + this.updateForm(response); + }).catch(error => { + this.msgHandler.handleError(error); + }); + } + + this.nameChecker.debounceTime(500).distinctUntilChanged().subscribe((ruleName: string) => { + this.isRuleNameExist = false; + this.inNameChecking = true; + toPromise(this.repService.getReplicationRules(0, ruleName)) + .then(response => { + if (response.some(rule => rule.name === ruleName)) { + this.ruleNameTooltip = 'TOOLTIP.RULE_USER_EXISTING'; + this.isRuleNameExist = true; + } + this.inNameChecking = false; + }).catch(() => { + this.inNameChecking = false; + }); + }); + } + + ngAfterViewInit(): void { + } + + ngOnDestroy(): void { + if (this.confirmSub) { + this.confirmSub.unsubscribe(); + } + if (this.nameChecker) { + this.nameChecker.unsubscribe(); + } + } + + createForm() { + this.ruleForm = this.fb.group({ + name: ['', Validators.required], + description: '', + projects: this.fb.array([]), + targets: this.fb.array([]), + trigger: this.fb.group({ + kind: this.triggerNames[0], + schedule_param: this.fb.group({ + type: this.scheduleNames[0], + weekday: 1, + offtime: '08:00' + }), + }), + filters: this.fb.array([]), + replicate_existing_image_now: true, + replicate_deletion: false + }); + + } + + updateForm(rule: ReplicationRule): void { + rule.trigger = this.updateTrigger(rule.trigger); + this.ruleForm.reset({ + name: rule.name, + description: rule.description, + trigger: rule.trigger, + replicate_existing_image_now: rule.replicate_existing_image_now, + replicate_deletion: rule.replicate_deletion + }); + this.setProject(rule.projects); + this.setTarget(rule.targets); + if (rule.filters) { + this.setFilter(rule.filters); + this.updateFilter(rule.filters); + } + + // Force refresh view + let hnd = setInterval(() => this.ref.markForCheck(), 100); + setTimeout(() => clearInterval(hnd), 2000); + } + + get projects(): FormArray { + return this.ruleForm.get('projects') as FormArray; + } + setProject(projects: Project[]) { + const projectFGs = projects.map(project => this.fb.group(project)); + const projectFormArray = this.fb.array(projectFGs); + this.ruleForm.setControl('projects', projectFormArray); + } + + get filters(): FormArray { + return this.ruleForm.get('filters') as FormArray; + } + setFilter(filters: Filter[]) { + const filterFGs = filters.map(filter => this.fb.group(filter)); + const filterFormArray = this.fb.array(filterFGs); + this.ruleForm.setControl('filters', filterFormArray); + } + + get targets(): FormArray { + return this.ruleForm.get('targets') as FormArray; + } + setTarget(targets: Target[]) { + const targetFGs = targets.map(target => this.fb.group(target)); + const targetFormArray = this.fb.array(targetFGs); + this.ruleForm.setControl('targets', targetFormArray); + } + + initFilter(name: string) { + return this.fb.group({ + kind: name, + pattern: ['', Validators.required] + }); + } + + filterChange($event: any) { + if ($event && $event.target['value']) { + let id: number = $event.target.id; + let name: string = $event.target.name; + let value: string = $event.target['value']; + + this.filterListData.forEach((data, index) => { + if (index === +id) { + data.name = $event.target.name = value; + }else { + data.options.splice(data.options.indexOf(value), 1); + } + if (data.options.indexOf(name) === -1) { + data.options.push(name); + } + }); + } + } + + targetChange($event: any) { + if ($event && $event.target && event.target['value']) { + let selecedTarget: Target = this.targetList.find(target => target.id === +$event.target['value']); + this.setTarget([selecedTarget]); + } + } + + openProjectModel(): void { + this.projectListModel.openModel(); + } + + selectedProject(project: Project): void { + this.setProject([project]); + } + + addNewFilter(): void { + if (this.filterCount === 0) { + this.filterListData.push(this.baseFilterData(this.filterSelect[0], this.filterSelect.slice(), true)); + this.filters.push(this.initFilter(this.filterSelect[0])); + + }else { + let nameArr: string[] = this.filterSelect.slice(); + this.filterListData.forEach(data => { + nameArr.splice(nameArr.indexOf(data.name), 1); + }); + // when add a new filter,the filterListData should change the options + this.filterListData.filter((data) => { + data.options.splice(data.options.indexOf(nameArr[0]), 1); + }); + this.filterListData.push(this.baseFilterData(nameArr[0], nameArr, true)); + this.filters.push(this.initFilter(nameArr[0])); + } + this.filterCount += 1; + if (this.filterCount >= this.filterSelect.length) { + this.isFilterHide = true; + } + } + + // delete a filter + deleteFilter(i: number): void { + if (i || i === 0) { + let delfilter = this.filterListData.splice(i, 1)[0]; + if (this.filterCount === this.filterSelect.length) { + this.isFilterHide = false; + } + this.filterCount -= 1; + if (this.filterListData.length) { + let optionVal = delfilter.name; + this.filterListData.filter(data => { + if (data.options.indexOf(optionVal) === -1) { + data.options.push(optionVal); + } + }); + } + const control = this.ruleForm.controls['filters']; + control.removeAt(i); + } + } + + selectTrigger($event: any): void { + if ($event && $event.target && $event.target['value']) { + let val: string = $event.target['value']; + if (val === this.triggerNames[1]) { + this.isScheduleOpt = true; + this.isImmediate = false; + } + if (val === this.triggerNames[0]) { + this.isScheduleOpt = false; + this.isImmediate = true; + } + if (val === this.triggerNames[2]) { + this.isScheduleOpt = false; + this.isImmediate = false; + } + } + } + + // Replication Schedule select value exchange + selectSchedule($event: any): void { + if ($event && $event.target && $event.target['value']) { + switch ($event.target['value']) { + case this.scheduleNames[1]: + this.weeklySchedule = true; + this.ruleForm.patchValue({ + trigger: { + schedule_param: { + weekday: 1, + } + } + }) + break; + case this.scheduleNames[0]: + this.weeklySchedule = false; + break; + } + } + } + + checkRuleName(): void { + let ruleName: string = this.ruleForm.controls['name'].value; + if (ruleName) { + this.nameChecker.next(ruleName); + } else { + this.ruleNameTooltip = 'TOOLTIP.EMPTY'; + } + } + + updateFilter(filters: any) { + let opt: string[] = this.filterSelect.slice(); + filters.forEach((filter: any) => { + opt.splice(opt.indexOf(filter.kind), 1); + }) + filters.forEach((filter: any) => { + let option: string [] = opt.slice(); + option.unshift(filter.kind); + this.filterListData.push(this.baseFilterData(filter.kind, option, true)); + }); + this.filterCount = filters.length; + if (filters.length === this.filterSelect.length) { + this.isFilterHide = true; + } + } + + updateTrigger(trigger: any) { + if (trigger['schedule_param']) { + this.isScheduleOpt = true; + this.isImmediate = false; + trigger['schedule_param']['offtime'] = this.getOfftime(trigger['schedule_param']['offtime']); + if (trigger['schedule_param']['weekday']) { + this.weeklySchedule = true; + }else { + // set default + trigger['schedule_param']['weekday'] = 1; + } + }else { + if (trigger['kind'] === this.triggerNames[2]) { + this.isImmediate = false; + } + trigger['schedule_param'] = { type: this.scheduleNames[0], + weekday: this.weekly[0], + offtime: '08:00'}; + } + return trigger; + } + + setTriggerVaule(trigger: any) { + if (!this.isScheduleOpt) { + delete trigger['schedule_param']; + return trigger; + }else { + if (!this.weeklySchedule) { + delete trigger['schedule_param']['weekday']; + }else { + trigger['schedule_param']['weekday'] = +trigger['schedule_param']['weekday']; + } + trigger['schedule_param']['offtime'] = this.setOfftime(trigger['schedule_param']['offtime']); + return trigger; + } + } + + public hasFormChange(): boolean { + return !isEmptyObject(this.getChanges()); + } + + + onSubmit() { + // add new Replication rule + let copyRuleForm: ReplicationRule = this.ruleForm.value; + copyRuleForm.trigger = this.setTriggerVaule(copyRuleForm.trigger); + if (!this.policyId) { + this.repService.createReplicationRule(copyRuleForm) + .then(() => { + this.msgHandler.showSuccess('REPLICATION.CREATED_SUCCESS'); + this.inProgress = false; + setTimeout(() => { + this.copyUpdateForm = Object.assign({}, this.ruleForm.value); + this.router.navigate(['/harbor/replications']); + }, 2000); + + }).catch((error: any) => { + this.inProgress = false; + this.msgHandler.handleError(error); + }); + } else { + this.repService.updateReplicationRule(this.policyId, this.ruleForm.value) + .then(() => { + this.msgHandler.showSuccess('REPLICATION.UPDATED_SUCCESS'); + this.inProgress = false; + setTimeout(() => { + this.copyUpdateForm = Object.assign({}, this.ruleForm.value); + this.router.navigate(['/harbor/replications']); + }, 2000); + + }).catch((error: any) => { + this.inProgress = false; + this.msgHandler.handleError(error); + }); + } + this.inProgress = true; + } + + onCancel(): void { + this.router.navigate(['/harbor/replications']); + } + + // UTC time + public getOfftime(daily_time: any): string { + + let timeOffset: number = 0; // seconds + if (daily_time && typeof daily_time === 'number') { + timeOffset = +daily_time; + } + + // Convert to current time + let timezoneOffset: number = this._localTime.getTimezoneOffset(); + // Local time + timeOffset = timeOffset - timezoneOffset * 60; + if (timeOffset < 0) { + timeOffset = timeOffset + ONE_DAY_SECONDS; + } + + if (timeOffset >= ONE_DAY_SECONDS) { + timeOffset -= ONE_DAY_SECONDS; + } + + // To time string + let hours: number = Math.floor(timeOffset / ONE_HOUR_SECONDS); + let minutes: number = Math.floor((timeOffset - hours * ONE_HOUR_SECONDS) / 60); + + let timeStr: string = '' + hours; + if (hours < 10) { + timeStr = '0' + timeStr; + } + if (minutes < 10) { + timeStr += ':0'; + } else { + timeStr += ':'; + } + timeStr += minutes; + + return timeStr; + } + public setOfftime(v: string) { + if (!v || v === '') { + return; + } + + let values: string[] = v.split(':'); + if (!values || values.length !== 2) { + return; + } + + let hours: number = +values[0]; + let minutes: number = +values[1]; + // Convert to UTC time + let timezoneOffset: number = this._localTime.getTimezoneOffset(); + let utcTimes: number = hours * ONE_HOUR_SECONDS + minutes * 60; + utcTimes += timezoneOffset * 60; + if (utcTimes < 0) { + utcTimes += ONE_DAY_SECONDS; + } + + if (utcTimes >= ONE_DAY_SECONDS) { + utcTimes -= ONE_DAY_SECONDS; + } + + return utcTimes; + } + + backReplication(): void { + this.router.navigate(['/harbor/replications']); + } + + getChanges(): { [key: string]: any | any[] } { + let changes: { [key: string]: any | any[] } = {}; + let ruleValue: { [key: string]: any | any[] } = this.ruleForm.value; + if (!ruleValue || !this.copyUpdateForm) { + return changes; + } + for (let prop in ruleValue) { + let field = this.copyUpdateForm[prop]; + if (!compareValue(field, ruleValue[prop])) { + changes[prop] = ruleValue[prop]; + //Number + if (typeof field === "number") { + changes[prop] = +changes[prop]; + } + + //Trim string value + if (typeof field === "string") { + changes[prop] = ('' + changes[prop]).trim(); + } + } + } + + return changes; + } + +} diff --git a/src/ui_ng/src/app/replication/replication-rule/replication-rule.css b/src/ui_ng/src/app/replication/replication-rule/replication-rule.css new file mode 100644 index 000000000..28e92dee5 --- /dev/null +++ b/src/ui_ng/src/app/replication/replication-rule/replication-rule.css @@ -0,0 +1,34 @@ +/** + * Created by pengf on 9/28/2017. + */ + +.select{ + width: 186px; +} +.select .optionMore{ + background-color: #bfbaba; + height: 1.6em; + font-size: 1.2em; + cursor: pointer; + text-align: center; +} +.hideFilter{ display: none;} +h4{ + color: #666; +} +label:first-child { + font-size: 15px; + left: -10px !important; +} +.endpointSelect{ width: 290px;} +.filterSelect{width: 320px;} +.filterSelect label{width: 160px;} +.filterSelect label input{width: 100%;} +.cursor{cursor: pointer;} +.padLeft0{padding-left: 0;} +.floatSet {display: inline-block; float: left; width: 120px;margin-right: 10px;} +.form-group{ min-height: 36px;} + +.projectInput{float: left;} +.projectInput input{width: 185px;background-color: white;} +.switchIcon{width:20px;height:20px; margin-top: 5px;margin-left: 15px;} \ No newline at end of file diff --git a/src/ui_ng/src/app/replication/replication-rule/replication-rule.html b/src/ui_ng/src/app/replication/replication-rule/replication-rule.html new file mode 100644 index 000000000..c763a07cc --- /dev/null +++ b/src/ui_ng/src/app/replication/replication-rule/replication-rule.html @@ -0,0 +1,122 @@ + + < {{'SIDE_NAV.SYSTEM_MGMT.REPLICATION' | translate}} + {{headerTitle | translate}} + + + + {{'REPLICATION.NAME' | translate}}* + + + {{ruleNameTooltip | translate}} + + + + + {{'REPLICATION.DESCRIPTION' | translate}} + + + + {{'REPLICATION.SOURCE' | translate}} + + {{'PROJECT.PROJECTS' | translate}}* + + + + + + + + + + + {{'REPLICATION.FILTER' | translate}} + + + + + + {{filter}} + + + + + {{'TOOLTIP.EMPTY' | translate}} + + + + + + + + + {{'REPLICATION.TARGETS' | translate}} + + {{'DESTINATION.ENDPOINT' | translate}} * + + + + {{target.name}}: {{target.endpoint}} + + + + + + + {{'REPLICATION.TRIGGER' | translate}} + + {{'REPLICATION.SCHEDULE' | translate}} + + + + + {{triggerName}} + + + + + + + {{scheduleName}} + + + + on + + + {{filter}} + + + + at + + + + + + {{'REPLICATION.DELETE_REMOTE_IMAGES' | translate}} + + + + + {{'REPLICATION.SETTING' | translate}} + + {{'REPLICATION.SETTING' | translate}} + + + {{'REPLICATION.REPLICATE_IMMEDIATE' | translate}} + + + + + + + + {{ 'BUTTON.CANCEL' | translate }} + {{ 'BUTTON.OK' | translate }} + + + + + \ No newline at end of file diff --git a/src/ui_ng/src/app/replication/replication-rule/replication-rule.service.ts b/src/ui_ng/src/app/replication/replication-rule/replication-rule.service.ts new file mode 100644 index 000000000..fbe5a1c4c --- /dev/null +++ b/src/ui_ng/src/app/replication/replication-rule/replication-rule.service.ts @@ -0,0 +1,75 @@ +/** + * Created by pengf on 12/5/2017. + */ + +import {Injectable} from "@angular/core"; +import {Http, RequestOptions, Headers, URLSearchParams} from "@angular/http"; +import {Observable} from "rxjs/Observable"; +import {ReplicationRule, Target} from "./replication-rule"; +import {HTTP_GET_OPTIONS, HTTP_JSON_OPTIONS} from "../../shared/shared.utils"; +import {Project} from "../../project/project"; + +@Injectable() +export class ReplicationRuleServie { + headers = new Headers({'Content-type': 'application/json'}); + options = new RequestOptions({'headers': this.headers}); + baseurl = '/api/policies/replication'; + targetUrl= '/api/targets'; + + constructor(private http: Http) {} + + public createReplicationRule(replicationRule: ReplicationRule): Observable | Promise | any { + /*if (!this._isValidRule(replicationRule)) { + return Promise.reject('Bad argument'); + }*/ + + return this.http.post(this.baseurl, JSON.stringify(replicationRule), this.options).toPromise() + .then(response => response) + .catch(error => Promise.reject(error)); + } + + public getReplicationRules(projectId?: number | string, ruleName?: string): Promise | ReplicationRule[] { + let queryParams = new URLSearchParams(); + if (projectId) { + queryParams.set('project_id', '' + projectId); + } + + if (ruleName) { + queryParams.set('name', ruleName); + } + + return this.http.get(this.baseurl, {search: queryParams}).toPromise() + .then(response => response.json() as ReplicationRule[]) + .catch(error => Promise.reject(error)); + } + + public getReplicationRule(policyId: number): Promise { + let url: string = `${this.baseurl}/${policyId}`; + return this.http.get(url, HTTP_GET_OPTIONS).toPromise() + .then(response => response.json() as ReplicationRule) + .catch(error => Promise.reject(error)); + } + + + public getEndpoints(): Promise | Target[] { + return this.http + .get(this.targetUrl) + .toPromise() + .then(response => response.json()) + .catch(error => Promise.reject(error)); + } + + public listProjects(): Promise | Project[] { + return this.http.get(`/api/projects`, HTTP_GET_OPTIONS).toPromise() + .then(response => response.json()) + .catch(error => Promise.reject(error)); + } + + public updateReplicationRule(id: number, rep: {[key: string]: any | any[] }): Observable | Promise | any { + let url: string = `${this.baseurl}/${id}`; + return this.http.put(url, JSON.stringify(rep), HTTP_JSON_OPTIONS).toPromise() + .then(response => response) + .catch(error => Promise.reject(error)); + } + +} diff --git a/src/ui_ng/src/app/replication/replication-rule/replication-rule.ts b/src/ui_ng/src/app/replication/replication-rule/replication-rule.ts new file mode 100644 index 000000000..9a02b8876 --- /dev/null +++ b/src/ui_ng/src/app/replication/replication-rule/replication-rule.ts @@ -0,0 +1,49 @@ +import {Project} from "../../project/project"; +/** + * Created by pengf on 12/7/2017. + */ + +export class Target { + id: 0; + endpoint: 'string'; + name: 'string'; + username: 'string'; + password: 'string'; + type: 0; + insecure: true; + creation_time: 'string'; + update_time: 'string'; +} + +export class Filter { + kind: string; + pattern: string; + constructor(kind: string, pattern: string) { + this.kind = kind; + this.pattern = pattern; + } +} + +export class Trigger { + kind: string; + schedule_param: any | { + [key: string]: any | any[]; + }; + constructor(kind: string, param: any | { [key: string]: any | any[]; }) { + this.kind = kind; + this.schedule_param = param; + } +} + +export interface ReplicationRule { + id?: number; + name: string; + description: string; + projects: Project[]; + targets: Target[] ; + trigger: Trigger ; + filters: Filter[] ; + replicate_existing_image_now?: boolean; + replicate_deletion?: boolean; +} + diff --git a/src/ui_ng/src/app/replication/replication.module.ts b/src/ui_ng/src/app/replication/replication.module.ts index cd20fada9..06cfca749 100644 --- a/src/ui_ng/src/app/replication/replication.module.ts +++ b/src/ui_ng/src/app/replication/replication.module.ts @@ -20,22 +20,31 @@ import { TotalReplicationPageComponent } from './total-replication/total-replica import { DestinationPageComponent } from './destination/destination-page.component'; import { SharedModule } from '../shared/shared.module'; +import {ReplicationRuleComponent} from "./replication-rule/replication-rule.component"; +import {ReactiveFormsModule} from "@angular/forms"; +import {ReplicationRuleServie} from "./replication-rule/replication-rule.service"; +import {ListProjectModelComponent} from "./replication-rule/list-project-model/list-project-model.component"; @NgModule({ imports: [ SharedModule, - RouterModule + RouterModule, + ReactiveFormsModule ], declarations: [ ReplicationPageComponent, ReplicationManagementComponent, TotalReplicationPageComponent, - DestinationPageComponent + DestinationPageComponent, + ReplicationRuleComponent, + ListProjectModelComponent, ], exports: [ ReplicationPageComponent, DestinationPageComponent, - TotalReplicationPageComponent - ] + TotalReplicationPageComponent, + ReplicationRuleComponent, + ], + providers: [ReplicationRuleServie] }) export class ReplicationModule { } \ No newline at end of file diff --git a/src/ui_ng/src/app/replication/total-replication/total-replication-page.component.html b/src/ui_ng/src/app/replication/total-replication/total-replication-page.component.html index b93ec2ca3..18dcdc455 100644 --- a/src/ui_ng/src/app/replication/total-replication/total-replication-page.component.html +++ b/src/ui_ng/src/app/replication/total-replication/total-replication-page.component.html @@ -1,3 +1,4 @@ +{{'SIDE_NAV.SYSTEM_MGMT.REPLICATION' | translate}} - + \ No newline at end of file diff --git a/src/ui_ng/src/app/replication/total-replication/total-replication-page.component.ts b/src/ui_ng/src/app/replication/total-replication/total-replication-page.component.ts index 9716c07d7..6f89caa2a 100644 --- a/src/ui_ng/src/app/replication/total-replication/total-replication-page.component.ts +++ b/src/ui_ng/src/app/replication/total-replication/total-replication-page.component.ts @@ -14,7 +14,7 @@ import { Component } from '@angular/core'; import {Router,ActivatedRoute} from "@angular/router"; -import {ReplicationRule} from "harbor-ui"; +import {ReplicationRule} from "../replication-rule/replication-rule"; @Component({ selector: 'total-replication', @@ -26,7 +26,15 @@ export class TotalReplicationPageComponent { private activeRoute: ActivatedRoute){} customRedirect(rule: ReplicationRule): void { if (rule) { - this.router.navigate(['../../projects', rule.project_id, "replications"], { relativeTo: this.activeRoute }); + this.router.navigate(['../projects', rule.projects[0].project_id, 'replications'], { relativeTo: this.activeRoute }); } } -} \ No newline at end of file + + openEditPage(id: number): void { + this.router.navigate([id, 'rule'], { relativeTo: this.activeRoute }); + } + + openCreatePage(): void { + this.router.navigate(['new-rule'], { relativeTo: this.activeRoute }); + } +} diff --git a/src/ui_ng/src/app/replication/total-replication/total-replication.component.css b/src/ui_ng/src/app/replication/total-replication/total-replication.component.css index 23904eade..8d38880dd 100644 --- a/src/ui_ng/src/app/replication/total-replication/total-replication.component.css +++ b/src/ui_ng/src/app/replication/total-replication/total-replication.component.css @@ -2,4 +2,7 @@ padding-right: 16px; margin-top: 36px; margin-bottom: 11px; -} \ No newline at end of file +} +.custom-h2 { + margin-top: 0px !important; +} diff --git a/src/ui_ng/src/app/shared/about-dialog/about-dialog.component.ts b/src/ui_ng/src/app/shared/about-dialog/about-dialog.component.ts index 400574750..f598d6f4d 100644 --- a/src/ui_ng/src/app/shared/about-dialog/about-dialog.component.ts +++ b/src/ui_ng/src/app/shared/about-dialog/about-dialog.component.ts @@ -35,7 +35,7 @@ export class AboutDialogComponent implements OnInit{ ngOnInit(): void { // custom skin - let customSkinObj = this.skinableConfig.getProjects(); + let customSkinObj = this.skinableConfig.getProject(); if (customSkinObj) { let selectedLang = this.translate.currentLang; this.customName = customSkinObj; diff --git a/src/ui_ng/src/app/shared/confirmation-dialog/confirmation-dialog.component.html b/src/ui_ng/src/app/shared/confirmation-dialog/confirmation-dialog.component.html index 4e74a1da7..5af5b73b8 100644 --- a/src/ui_ng/src/app/shared/confirmation-dialog/confirmation-dialog.component.html +++ b/src/ui_ng/src/app/shared/confirmation-dialog/confirmation-dialog.component.html @@ -29,7 +29,7 @@ {{'BUTTON.CANCEL' | translate}} - {{'BUTTON.DELETE' | translate}} + {{'BUTTON.DELETE' | translate}} {{'BUTTON.CLOSE' | translate}} diff --git a/src/ui_ng/src/app/shared/confirmation-dialog/confirmation-dialog.component.ts b/src/ui_ng/src/app/shared/confirmation-dialog/confirmation-dialog.component.ts index 0d8d693b2..9182f66cb 100644 --- a/src/ui_ng/src/app/shared/confirmation-dialog/confirmation-dialog.component.ts +++ b/src/ui_ng/src/app/shared/confirmation-dialog/confirmation-dialog.component.ts @@ -112,7 +112,7 @@ export class ConfirmationDialogComponent implements OnDestroy { this.close(); } - confirm(): void { + delete(): void { if(!this.message){//Inproper condition this.close(); return; @@ -130,6 +130,21 @@ export class ConfirmationDialogComponent implements OnDestroy { data, target )); + } + confirm(): void { + if(!this.message){//Inproper condition + this.close(); + return; + } + + let data: any = this.message.data ? this.message.data : {}; + let target = this.message.targetId ? this.message.targetId : ConfirmationTargets.EMPTY; + this.confirmationService.confirm(new ConfirmationAcknowledgement( + ConfirmationState.CONFIRMED, + data, + target + )); + this.close(); } } \ No newline at end of file diff --git a/src/ui_ng/src/app/shared/route/leaving-new-rule-deactivate.service.ts b/src/ui_ng/src/app/shared/route/leaving-new-rule-deactivate.service.ts new file mode 100644 index 000000000..60ea8de74 --- /dev/null +++ b/src/ui_ng/src/app/shared/route/leaving-new-rule-deactivate.service.ts @@ -0,0 +1,65 @@ +// Copyright (c) 2017 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. +import { Injectable } from '@angular/core'; +import { + CanDeactivate, Router, + ActivatedRouteSnapshot, + RouterStateSnapshot +} from '@angular/router'; + +import { ConfirmationDialogService } from '../confirmation-dialog/confirmation-dialog.service'; + +import { ConfigurationComponent } from '../../config/config.component'; +import { ConfirmationMessage } from '../confirmation-dialog/confirmation-message'; +import { ConfirmationState, ConfirmationTargets } from '../shared.const'; +import {ReplicationRuleComponent} from "../../replication/replication-rule/replication-rule.component"; + +@Injectable() +export class LeavingNewRuleRouteDeactivate implements CanDeactivate { + constructor( + private router: Router, + private confirmation: ConfirmationDialogService) { } + + canDeactivate( + replicateRule: ReplicationRuleComponent, + route: ActivatedRouteSnapshot, + state: RouterStateSnapshot): Promise | boolean { + //Confirmation before leaving config route + return new Promise((resolve, reject) => { + if (replicateRule && replicateRule.hasFormChange()) { + let msg: ConfirmationMessage = new ConfirmationMessage( + "CONFIG.LEAVING_CONFIRMATION_TITLE", + "CONFIG.LEAVING_CONFIRMATION_SUMMARY", + '', + {}, + ConfirmationTargets.CONFIG_ROUTE + ); + this.confirmation.openComfirmDialog(msg); + return this.confirmation.confirmationConfirm$.subscribe(msg => { + if (msg && msg.source === ConfirmationTargets.CONFIG_ROUTE) { + if (msg.state === ConfirmationState.CONFIRMED) { + return resolve(true); + } else { + return resolve(false);//Prevent leading route + } + } else { + return resolve(true);//Should go on + } + }); + } else { + return resolve(true); + } + }); + } +} diff --git a/src/ui_ng/src/app/shared/shared.module.ts b/src/ui_ng/src/app/shared/shared.module.ts index 20b360fcd..c50550921 100644 --- a/src/ui_ng/src/app/shared/shared.module.ts +++ b/src/ui_ng/src/app/shared/shared.module.ts @@ -58,6 +58,7 @@ import { ErrorHandler, HarborLibraryModule } from 'harbor-ui'; +import {LeavingNewRuleRouteDeactivate} from "./route/leaving-new-rule-deactivate.service"; const uiLibConfig: IServiceConfig = { enablei18Support: true, @@ -123,6 +124,7 @@ const uiLibConfig: IServiceConfig = { AuthCheckGuard, SignInGuard, LeavingConfigRouteDeactivate, + LeavingNewRuleRouteDeactivate, MemberGuard, MessageHandlerService, StatisticHandler diff --git a/src/ui_ng/src/app/skinable-config.service.ts b/src/ui_ng/src/app/skinable-config.service.ts index 5db164786..b1ae140c8 100644 --- a/src/ui_ng/src/app/skinable-config.service.ts +++ b/src/ui_ng/src/app/skinable-config.service.ts @@ -12,7 +12,7 @@ export class SkinableConfig { constructor(private http: Http) {} public getCustomFile(): Promise { - return this.http.get('../setting.json') + return this.http.get('../static/setting.json') .toPromise() .then(response => { this.customSkinData = response.json(); return this.customSkinData; }) .catch(error => { @@ -24,9 +24,9 @@ export class SkinableConfig { return this.customSkinData; } - public getProjects() { + public getProject() { if (this.customSkinData) { - return this.customSkinData.projects; + return this.customSkinData.project; }else { return null; } diff --git a/src/ui_ng/src/i18n/lang/en-us-lang.json b/src/ui_ng/src/i18n/lang/en-us-lang.json index 071639669..14b8990d0 100644 --- a/src/ui_ng/src/i18n/lang/en-us-lang.json +++ b/src/ui_ng/src/i18n/lang/en-us-lang.json @@ -55,7 +55,9 @@ "NUMBER_REQUIRED": "Field is required and should be numbers.", "PORT_REQUIRED": "Field is required and should be valid port number.", "EMAIL_EXISTING": "Email address already exists.", - "USER_EXISTING": "Username is already in use." + "USER_EXISTING": "Username is already in use.", + "RULE_USER_EXISTING": "Name is already in use.", + "EMPTY": "Name is required" }, "PLACEHOLDER": { "CURRENT_PWD": "Enter current password", @@ -100,7 +102,8 @@ "SYSTEM_MGMT": { "NAME": "Administration", "USER": "Users", - "REPLICATION": "Replication", + "REGISTRY": "Registries", + "REPLICATION": "Replications", "CONFIG": "Configuration" }, "LOGS": "Logs" @@ -301,7 +304,17 @@ "INVALID_DATE": "Invalid date.", "PLACEHOLDER": "We couldn't find any replication rules!", "JOB_PLACEHOLDER": "We couldn't find any replication jobs!", - "JOB_LOG_VIEWER": "View Replication Job Log" + "JOB_LOG_VIEWER": "View Replication Job Log", + "BACKINFO": "Please add project and endpoint first", + "FILTER": "Filter", + "SCHEDULE": "Schedule", + "SETTING":"Setting", + "TRIGGER":"Trigger", + "TARGETS":"Target", + "SOURCE": "Source", + "REPLICATE": "Replicate", + "DELETE_REMOTE_IMAGES":"Delete remote images when locally deleted", + "REPLICATE_IMMEDIATE":"Replicate exiting images immediately" }, "DESTINATION": { "NEW_ENDPOINT": "New Endpoint", @@ -315,7 +328,7 @@ "TEST_CONNECTION": "Test Connection", "TITLE_EDIT": "Edit Endpoint", "TITLE_ADD": "Create Endpoint", - "DELETE": "Delete", + "DELETE": "Delete Endpoint", "TESTING_CONNECTION": "Testing Connection...", "TEST_CONNECTION_SUCCESS": "Connection tested successfully.", "TEST_CONNECTION_FAILURE": "Failed to ping endpoint.", diff --git a/src/ui_ng/src/i18n/lang/es-es-lang.json b/src/ui_ng/src/i18n/lang/es-es-lang.json index 1c0a4b161..0b46206f8 100644 --- a/src/ui_ng/src/i18n/lang/es-es-lang.json +++ b/src/ui_ng/src/i18n/lang/es-es-lang.json @@ -51,7 +51,9 @@ "NUMBER_REQUIRED": "El campo es obligatorio y debería ser un número.", "PORT_REQUIRED": "El campo es obligatorio y debería ser un número de puerto válido.", "EMAIL_EXISTING": "Esa dirección de email ya existe.", - "USER_EXISTING": "Ese nombre de usuario ya existe." + "USER_EXISTING": "Ese nombre de usuario ya existe.", + "RULE_USER_EXISTING": "Name is already in use.", + "EMPTY": "Name is required" }, "PLACEHOLDER": { "CURRENT_PWD": "Introduzca la contraseña actual", @@ -96,7 +98,8 @@ "SYSTEM_MGMT": { "NAME": "Administración", "USER": "Usuarios", - "REPLICATION": "Replicación", + "REGISTRY": "Registries", + "REPLICATION": "Replicacións", "CONFIG": "Configuración" }, "LOGS": "Logs" @@ -297,7 +300,16 @@ "INVALID_DATE": "Fecha invalida.", "PLACEHOLDER": "We couldn't find any replication rules!", "JOB_PLACEHOLDER": "We couldn't find any replication jobs!", - "JOB_LOG_VIEWER": "View Replication Job Log" + "JOB_LOG_VIEWER": "View Replication Job Log", + "BACKINFO": "Please add project and endpoint first", + "FILTER": "Filter", + "SCHEDULE": "Schedule", + "SETTING":"Setting", + "TRIGGER":"Trigger", + "TARGETS":"Target", + "SOURCE": "Source", + "DELETE_REMOTE_IMAGES":"Delete remote images when locally deleted", + "REPLICATE_IMMEDIATE":"Replicate exiting images immediately" }, "DESTINATION": { "NEW_ENDPOINT": "Nuevo Endpoint", @@ -311,7 +323,7 @@ "TEST_CONNECTION": "Comprobar conexión", "TITLE_EDIT": "Editar Endpoint", "TITLE_ADD": "Crear Endpoint", - "DELETE": "Eliminar", + "DELETE": "Eliminar Endpoint", "TESTING_CONNECTION": "Comprobar conexión...", "TEST_CONNECTION_SUCCESS": "Conexión comprobada satisfactoriamente.", "TEST_CONNECTION_FAILURE": "Fallo al comprobar el endpoint.", diff --git a/src/ui_ng/src/i18n/lang/zh-cn-lang.json b/src/ui_ng/src/i18n/lang/zh-cn-lang.json index f498730c5..514ce55eb 100644 --- a/src/ui_ng/src/i18n/lang/zh-cn-lang.json +++ b/src/ui_ng/src/i18n/lang/zh-cn-lang.json @@ -51,7 +51,9 @@ "NUMBER_REQUIRED": "此项为必填项且为数字。", "PORT_REQUIRED": "此项为必填项且为合法端口号。", "EMAIL_EXISTING": "邮件地址已经存在。", - "USER_EXISTING": "用户名已经存在。" + "USER_EXISTING": "用户名已经存在。", + "RULE_USER_EXISTING": "名称已经存在。", + "EMPTY": "名称为必填项" }, "PLACEHOLDER": { "CURRENT_PWD": "输入当前密码", @@ -96,6 +98,7 @@ "SYSTEM_MGMT": { "NAME": "系统管理", "USER": "用户管理", + "REGISTRY": "仓库管理", "REPLICATION": "复制管理", "CONFIG": "配置管理" }, @@ -297,7 +300,16 @@ "INVALID_DATE": "无效日期。", "PLACEHOLDER": "未发现任何复制规则!", "JOB_PLACEHOLDER": "未发现任何复制任务!", - "JOB_LOG_VIEWER": "查看复制任务日志" + "JOB_LOG_VIEWER": "查看复制任务日志", + "BACKINFO": "请先添加项目名称和目标", + "FILTER": "过滤", + "SCHEDULE": "日程", + "SETTING":"设置", + "TRIGGER":"触发器", + "TARGETS":"目标", + "SOURCE": "资源", + "DELETE_REMOTE_IMAGES":"删除本地镜像时同时也删除远程的镜像。", + "REPLICATE_IMMEDIATE":"立即复制现有的镜像。" }, "DESTINATION": { "NEW_ENDPOINT": "新建目标", @@ -311,7 +323,7 @@ "TEST_CONNECTION": "测试连接", "TITLE_EDIT": "编辑目标", "TITLE_ADD": "新建目标", - "DELETE": "删除", + "DELETE": "删除目标", "TESTING_CONNECTION": "正在测试连接...", "TEST_CONNECTION_SUCCESS": "测试连接成功。", "TEST_CONNECTION_FAILURE": "测试连接失败。", diff --git a/src/ui_ng/src/setting.json b/src/ui_ng/src/setting.json index ad411fd58..c02f826ba 100644 --- a/src/ui_ng/src/setting.json +++ b/src/ui_ng/src/setting.json @@ -1,11 +1,11 @@ { - "headerBgColor": "#004a70", + "headerBgColor": "", "headerLogo": "", "loginBgImg": "", - "appTitle": "VMware Harbor", - "projects": { - "companyName": "vmware", - "projectName": "Harbor", + "appTitle": "", + "project": { + "companyName": "", + "projectName": "", "introduction": { "zh-cn": "", "es-es": "", diff --git a/tests/resources/Harbor-Pages/Administration-Users_Elements.robot b/tests/resources/Harbor-Pages/Administration-Users_Elements.robot index b1aad665f..b278ae6b6 100644 --- a/tests/resources/Harbor-Pages/Administration-Users_Elements.robot +++ b/tests/resources/Harbor-Pages/Administration-Users_Elements.robot @@ -16,5 +16,5 @@ Documentation This resource provides any keywords related to the Harbor private registry appliance *** Variables *** -${administration_user_tag_xpath} /html/body/harbor-app/harbor-shell/clr-main-container/div/nav/section/section/ul/li[1]/a -${administration_tag_xpath} /html/body/harbor-app/harbor-shell/clr-main-container/div/nav/section/section/label \ No newline at end of file +${administration_user_tag_xpath} //clr-vertical-nav-group-children/a[contains(.,'Users')] +${administration_tag_xpath} //clr-vertical-nav-group[contains(.,'Admin')] \ No newline at end of file diff --git a/tests/resources/Harbor-Pages/Configuration.robot b/tests/resources/Harbor-Pages/Configuration.robot index 14a6a928d..0afa72da0 100644 --- a/tests/resources/Harbor-Pages/Configuration.robot +++ b/tests/resources/Harbor-Pages/Configuration.robot @@ -38,14 +38,14 @@ Init LDAP Sleep 1 Capture Page Screenshot Disable Ldap Verify Cert Checkbox - Click Element xpath=/html/body/harbor-app/harbor-shell/clr-main-container/div/div/config/div/div/div/button[1] + Click Element xpath=${config_save_button_xpath} Sleep 2 Click Element xpath=/html/body/harbor-app/harbor-shell/clr-main-container/div/div/config/div/div/div/button[3] Sleep 1 Capture Page Screenshot Switch To Configure - Click Element xpath=/html/body/harbor-app/harbor-shell/clr-main-container/div/nav/section/section/ul/li[3]/a + Click Element xpath=${configuration_xpath} Sleep 2 Test Ldap Connection @@ -89,32 +89,32 @@ Ldap Verify Cert Checkbox Should Be Disabled Set Pro Create Admin Only #set limit to admin only Sleep 2 - Click Element xpath=//clr-main-container//nav//ul/li[3] + Click Element xpath=${configuration_xpath} Sleep 1 Click Element xpath=//select[@id="proCreation"] Click Element xpath=//select[@id="proCreation"]//option[@value="adminonly"] Sleep 1 - Click Element xpath=/html/body/harbor-app/harbor-shell/clr-main-container/div/div/config/div/div/div/button[1] + Click Element xpath=${config_save_button_xpath} Capture Page Screenshot AdminCreateOnly.png Set Pro Create Every One - #set limit to Every One - Click Element xpath=//clr-main-container//nav//ul/li[3] + #set limit to Every One + Click Element xpath=${configuration_xpath} Sleep 1 Click Element xpath=//select[@id="proCreation"] Click Element xpath=//select[@id="proCreation"]//option[@value="everyone"] Sleep 1 - Click Element xpath=/html/body/harbor-app/harbor-shell/clr-main-container/div/div/config/div/div/div/button[1] + Click Element xpath=${config_save_button_xpath} Sleep 2 Capture Page Screenshot EveryoneCreate.png -Disable Self Reg - Click Element xpath=//clr-main-container//nav//ul/li[3] +Disable Self Reg + Click Element xpath=${configuration_xpath} Mouse Down xpath=${self_reg_xpath} Mouse Up xpath=${self_reg_xpath} Sleep 1 Self Reg Should Be Disabled - Click Element xpath=/html/body/harbor-app/harbor-shell/clr-main-container/div/div/config/div/div/div/button[1] + Click Element xpath=${config_save_button_xpath} Capture Page Screenshot DisableSelfReg.png Sleep 1 @@ -123,7 +123,7 @@ Enable Self Reg Mouse Up xpath=${self_reg_xpath} Sleep 1 Self Reg Should Be Enabled - Click Element xpath=/html/body/harbor-app/harbor-shell/clr-main-container/div/div/config/div/div/div/button[1] + Click Element xpath=${config_save_button_xpath} Capture Page Screenshot EnableSelfReg.png Sleep 1 @@ -142,13 +142,13 @@ Project Creation Should Not Display ## System settings Switch To System Settings Sleep 1 - Click Element xpath=//clr-main-container//nav//ul/li[3] + Click Element xpath=${configuration_xpath} Click Element xpath=//*[@id="config-system"] Modify Token Expiration [Arguments] ${minutes} Input Text xpath=//*[@id="tokenExpiration"] ${minutes} - Click Button xpath=/html/body/harbor-app/harbor-shell/clr-main-container/div/div/config/div/div/div/button[1] + Click Button xpath=${config_save_button_xpath} Sleep 1 Token Must Be Match @@ -159,7 +159,7 @@ Token Must Be Match Check Verify Remote Cert Mouse Down xpath=//*[@id="clr-checkbox-verifyRemoteCert"] Mouse Up xpath=//*[@id="clr-checkbox-verifyRemoteCert"] - Click Element xpath=/html/body/harbor-app/harbor-shell/clr-main-container/div/div/config/div/div/div/button[1] + Click Element xpath=${config_save_button_xpath} Capture Page Screenshot RemoteCert.png Sleep 1 @@ -191,7 +191,7 @@ Config Email Mouse Down xpath=//*[@id="clr-checkbox-emailInsecure"] Mouse Up xpath=//*[@id="clr-checkbox-emailInsecure"] Sleep 1 - Click Element xpath=/html/body/harbor-app/harbor-shell/clr-main-container/div/div/config/div/div/div/button[1] + Click Element xpath=${config_save_button_xpath} Sleep 6 Verify Email @@ -206,11 +206,11 @@ Set Scan All To None click element //vulnerability-config//select click element //vulnerability-config//select/option[@value='none'] sleep 1 - click element //config//div/button[contains(.,'SAVE')] + click element ${config_save_button_xpath} Set Scan All To Daily click element //vulnerability-config//select click element //vulnerability-config//select/option[@value='daily'] sleep 1 - click element //config//div/button[contains(.,'SAVE')] + click element ${config_save_button_xpath} Click Scan Now click element //vulnerability-config//button[contains(.,'SCAN')] \ No newline at end of file diff --git a/tests/resources/Harbor-Pages/Configuration_Elements.robot b/tests/resources/Harbor-Pages/Configuration_Elements.robot index 04bd2ffa2..8cb6649b5 100644 --- a/tests/resources/Harbor-Pages/Configuration_Elements.robot +++ b/tests/resources/Harbor-Pages/Configuration_Elements.robot @@ -19,3 +19,5 @@ Documentation This resource provides any keywords related to the Harbor private ${project_create_xpath} //clr-dg-action-bar//button[contains(.,'New')] ${self_reg_xpath} //input[@id="clr-checkbox-selfReg"] ${test_ldap_xpath} /html/body/harbor-app/harbor-shell/clr-main-container/div/div/config/div/div/div/button[3] +${config_save_button_xpath} //config//div/button[contains(.,'SAVE')] +${configuration_xpath} //clr-vertical-nav-group-children/a[contains(.,'Configuration')] \ No newline at end of file diff --git a/tests/resources/Harbor-Pages/Project.robot b/tests/resources/Harbor-Pages/Project.robot index 196a493c4..0ee7c2e5a 100644 --- a/tests/resources/Harbor-Pages/Project.robot +++ b/tests/resources/Harbor-Pages/Project.robot @@ -56,7 +56,7 @@ Switch To Log Sleep 1 Switch To Replication - Click Element xpath=${replication_xpath} + Click Element xpath=${project_replication_xpath} Sleep 1 Back To projects diff --git a/tests/resources/Harbor-Pages/Project_Elements.robot b/tests/resources/Harbor-Pages/Project_Elements.robot index 0902c3ecf..57175ee05 100644 --- a/tests/resources/Harbor-Pages/Project_Elements.robot +++ b/tests/resources/Harbor-Pages/Project_Elements.robot @@ -20,6 +20,6 @@ ${create_project_button_css} .btn ${project_name_xpath} //*[@id="create_project_name"] ${project_public_xpath} //input[@name='public']/..//label ${project_save_css} html body.no-scrolling harbor-app harbor-shell clr-main-container.main-container div.content-container div.content-area.content-area-override project div.row div.col-lg-12.col-md-12.col-sm-12.col-xs-12 div.row.flex-items-xs-between div.option-left create-project clr-modal div.modal div.modal-dialog div.modal-content div.modal-footer button.btn.btn-primary -${log_xpath} /html/body/harbor-app/harbor-shell/clr-main-container/div/nav/section/a[2] -${projects_xpath} /html/body/harbor-app/harbor-shell/clr-main-container/div/nav/section/a[1] -${replication_xpath} /html/body/harbor-app/harbor-shell/clr-main-container/div/div/project-detail/nav/ul/li[4]/a +${log_xpath} //clr-main-container/div/clr-vertical-nav/div/a[contains(.,'Logs')] +${projects_xpath} //clr-main-container/div/clr-vertical-nav/div/a[contains(.,'Projects')] +${project_replication_xpath} //project-detail//a[contains(.,'Replication')] \ No newline at end of file diff --git a/tests/resources/Harbor-Pages/Replication.robot b/tests/resources/Harbor-Pages/Replication.robot index 3f142c6ef..62e5b6e37 100644 --- a/tests/resources/Harbor-Pages/Replication.robot +++ b/tests/resources/Harbor-Pages/Replication.robot @@ -22,6 +22,7 @@ ${HARBOR_VERSION} v1.1.1 *** Keywords *** Create An New Rule With New Endpoint [Arguments] ${policy_name} ${policy_description} ${destination_name} ${destination_url} ${destination_username} ${destination_password} + Click element ${new_name_xpath} Sleep 2 @@ -30,15 +31,14 @@ Create An New Rule With New Endpoint #Click element xpath=${policy_enable_checkbox} #enable attribute is droped in new ui + Click element xpath=${policy_endpoint_checkbox} - Input text xpath=${destination_name_xpath} ${destination_name} - Input text xpath=${destination_url_xpath} ${destination_url} - Input text xpath=${destination_username_xpath} ${destination_username} - Input text xpath=${destination_password_xpath} ${destination_password} - Click element xpath=${replicaton_save_xpath} + Click element xpath=//*[@id="ruleBtnOk"] Sleep 5 Capture Page Screenshot rule_${policy_name}.png Wait Until Page Contains ${policy_name} + Wait Until Page Contains ${policy_description} Wait Until Page Contains ${destination_name} + diff --git a/tests/resources/Harbor-Pages/Replication_Elements.robot b/tests/resources/Harbor-Pages/Replication_Elements.robot index 3265678a2..0976ddf1c 100644 --- a/tests/resources/Harbor-Pages/Replication_Elements.robot +++ b/tests/resources/Harbor-Pages/Replication_Elements.robot @@ -26,3 +26,4 @@ ${destination_url_xpath} //*[@id='destination_url'] ${destination_username_xpath} //*[@id='destination_username'] ${destination_password_xpath} //*[@id='destination_password'] ${replicaton_save_xpath} //button[contains(.,'OK')] +${replication_xpath} //clr-vertical-nav-group-children/a[contains(.,'Replication')] \ No newline at end of file diff --git a/tests/resources/Harbor-Pages/UserProfile.robot b/tests/resources/Harbor-Pages/UserProfile.robot index b8d0990f4..6e20cf16f 100644 --- a/tests/resources/Harbor-Pages/UserProfile.robot +++ b/tests/resources/Harbor-Pages/UserProfile.robot @@ -31,7 +31,7 @@ Change Password Sleep 1 Click Element xpath=//password-setting/clr-modal//button[2] Sleep 2 - Click Element xpath=/html/body/harbor-app/harbor-shell/clr-main-container/div/nav/section/a[2] + Click Element xpath=${log_xpath} Sleep 1 Update User Comment diff --git a/tests/robot-cases/Group0-BAT/BAT.robot b/tests/robot-cases/Group0-BAT/BAT.robot index 17b6d6328..9d51d091b 100644 --- a/tests/robot-cases/Group0-BAT/BAT.robot +++ b/tests/robot-cases/Group0-BAT/BAT.robot @@ -237,15 +237,15 @@ Test Case - Edit Token Expire Modify Token Expiration 30 Close Browser -Test Case - Create An Replication Rule New Endpoint - Init Chrome Driver - ${d}= Get current date result_format=%m%s - Sign In Harbor ${HARBOR_URL} %{HARBOR_ADMIN} %{HARBOR_PASSWORD} - Create An New Project project${d} - Go Into Project project${d} - Switch To Replication - Create An New Rule With New Endpoint policy_name=test_policy_${d} policy_description=test_description destination_name=test_destination_name_${d} destination_url=test_destination_url_${d} destination_username=test_destination_username destination_password=test_destination_password - Close Browser +# Test Case - Create An Replication Rule New Endpoint +# Init Chrome Driver +# ${d}= Get current date result_format=%m%s +# Sign In Harbor ${HARBOR_URL} %{HARBOR_ADMIN} %{HARBOR_PASSWORD} +# Create An New Project project${d} +# Go Into Project project${d} +# Switch To Replication +# Create An New Rule With New Endpoint policy_name=test_policy_${d} policy_description=test_description destination_name=test_destination_name_${d} destination_url=test_destination_url_${d} destination_username=test_destination_username destination_password=test_destination_password +# Close Browser Test Case - Scan A Tag In The Repo Init Chrome Driver
{{ imageInfo }}
{{'REPOSITORY.NO_INFO' | translate }}
+