From 2b78b553cb9d0e0885aa1273e144627cddd0ef5d Mon Sep 17 00:00:00 2001 From: Pedro Gonzalez Gomez Date: Tue, 16 Sep 2025 16:00:22 +0200 Subject: [PATCH] mgr/dashboard: carbonize service form Also updates service controller to fix snmp-gateway creation issue where service_id is not wanted on the request Fixes: https://tracker.ceph.com/issues/73098 Fixes: https://tracker.ceph.com/issues/74216 Signed-off-by: Pedro Gonzalez Gomez --- .../mgr/dashboard/controllers/service.py | 2 + .../cypress/e2e/cluster/inventory.po.ts | 6 +- .../cypress/e2e/cluster/services.po.ts | 28 +- .../workflow/08-hosts.e2e-spec.ts | 1 + .../src/app/ceph/cluster/cluster.module.ts | 8 +- .../service-form/service-form.component.html | 2711 +++++++++-------- .../service-form.component.spec.ts | 58 +- .../service-form/service-form.component.ts | 198 +- .../cluster/services/services.component.ts | 7 +- .../src/app/shared/forms/cd-validators.ts | 5 +- 10 files changed, 1561 insertions(+), 1463 deletions(-) diff --git a/src/pybind/mgr/dashboard/controllers/service.py b/src/pybind/mgr/dashboard/controllers/service.py index b75f417361498..4e419b4138440 100644 --- a/src/pybind/mgr/dashboard/controllers/service.py +++ b/src/pybind/mgr/dashboard/controllers/service.py @@ -66,6 +66,8 @@ class Service(RESTController): :return: None """ + if service_spec.get('service_type') not in ServiceSpec.REQUIRES_SERVICE_ID: + service_spec.pop('service_id', None) OrchClient.instance().services.apply(service_spec, no_overwrite=True) @UpdatePermission diff --git a/src/pybind/mgr/dashboard/frontend/cypress/e2e/cluster/inventory.po.ts b/src/pybind/mgr/dashboard/frontend/cypress/e2e/cluster/inventory.po.ts index 9230a26720a4a..629510862534e 100644 --- a/src/pybind/mgr/dashboard/frontend/cypress/e2e/cluster/inventory.po.ts +++ b/src/pybind/mgr/dashboard/frontend/cypress/e2e/cluster/inventory.po.ts @@ -10,7 +10,11 @@ export class InventoryPageHelper extends PageHelper { identify() { // Nothing we can do, just verify the form is there this.getFirstTableCell().click(); - cy.contains('[data-testid="primary-action"]', 'Identify').should('not.be.disabled').click(); + cy.wait(500); + cy.contains('[data-testid="primary-action"]', 'Identify') + .should('exist') + .should('not.be.disabled', { timeout: 15000 }) + .click(); cy.get('cds-modal').within(() => { cy.get('#duration').select('15 minutes'); cy.get('#duration').select('10 minutes'); diff --git a/src/pybind/mgr/dashboard/frontend/cypress/e2e/cluster/services.po.ts b/src/pybind/mgr/dashboard/frontend/cypress/e2e/cluster/services.po.ts index 5286676634e33..72eeb37a2efbd 100644 --- a/src/pybind/mgr/dashboard/frontend/cypress/e2e/cluster/services.po.ts +++ b/src/pybind/mgr/dashboard/frontend/cypress/e2e/cluster/services.po.ts @@ -50,13 +50,13 @@ export class ServicesPageHelper extends PageHelper { case 'rgw': cy.get('#service_id').type('foo'); unmanaged - ? cy.get('label[for=unmanaged]').click() + ? cy.get('cds-checkbox#unmanaged input[type="checkbox"]').check({ force: true }) : cy.get('#count').clear().type(String(count)); break; case 'ingress': if (unmanaged) { - cy.get('label[for=unmanaged]').click(); + cy.get('cds-checkbox#unmanaged input[type="checkbox"]').check({ force: true }); } this.selectOption('backend_service', 'rgw.foo'); cy.get('#service_id').should('have.value', 'rgw.foo'); @@ -68,14 +68,14 @@ export class ServicesPageHelper extends PageHelper { case 'nfs': cy.get('#service_id').type('testnfs'); unmanaged - ? cy.get('label[for=unmanaged]').click() + ? cy.get('cds-checkbox#unmanaged input[type="checkbox"]').check({ force: true }) : cy.get('#count').clear().type(String(count)); break; case 'smb': cy.get('#service_id').type('testsmb'); unmanaged - ? cy.get('label[for=unmanaged]').click() + ? cy.get('cds-checkbox#unmanaged input[type="checkbox"]').check({ force: true }) : cy.get('#count').clear().type(String(count)); cy.get('#cluster_id').type('cluster_foo'); cy.get('#config_uri').type('rados://.smb/foo/scc.toml'); @@ -108,19 +108,20 @@ export class ServicesPageHelper extends PageHelper { cy.get('#oidc_issuer_url').type('http://127.0.0.0:8080/realms/ceph'); break; + case 'mgmt-gateway': + cy.get('#port').clear().type('8443'); + cy.get('cds-checkbox#enable_auth input[type="checkbox"]').check({ force: true }); + break; + default: cy.get('#service_id').type('test'); unmanaged - ? cy.get('label[for=unmanaged]').click() + ? cy.get('cds-checkbox#unmanaged input[type="checkbox"]').check({ force: true }) : cy.get('#count').clear().type(String(count)); break; } cy.wait(1000); - if (serviceType === 'snmp-gateway') { - cy.get('cd-submit-button').dblclick(); - } else { - cy.get('cd-submit-button').click(); - } + cy.get('cd-submit-button').click(); }); if (exist) { cy.get('#service_id').should('have.class', 'ng-invalid'); @@ -133,7 +134,7 @@ export class ServicesPageHelper extends PageHelper { editService(name: string, daemonCount: string) { this.navigateEdit(name, true, false); cy.get(`${this.pages.create.id}`).within(() => { - cy.get('#service_type').should('be.disabled'); + cy.get('cds-select#service_type select').should('be.disabled'); cy.get('#service_id').should('be.disabled'); cy.get('#count').clear().type(daemonCount); cy.get('cd-submit-button').click(); @@ -203,9 +204,8 @@ export class ServicesPageHelper extends PageHelper { deleteService(serviceName: string) { this.clickRowActionButton(serviceName, 'delete', 3 * 1000); - // Confirms deletion - cy.get('cds-modal input#confirmation_input').click({ force: true }); - cy.contains('cds-modal button', 'Delete').click(); + cy.get('cds-modal [aria-label="confirmation"]').click({ force: true }); + cy.get('cds-modal button').contains('Delete', { matchCase: false }).click({ force: true }); // Wait for modal to close cy.get('cds-modal').should('not.exist'); diff --git a/src/pybind/mgr/dashboard/frontend/cypress/e2e/orchestrator/workflow/08-hosts.e2e-spec.ts b/src/pybind/mgr/dashboard/frontend/cypress/e2e/orchestrator/workflow/08-hosts.e2e-spec.ts index 6deff9fdc1fec..41a50cb2e2873 100644 --- a/src/pybind/mgr/dashboard/frontend/cypress/e2e/orchestrator/workflow/08-hosts.e2e-spec.ts +++ b/src/pybind/mgr/dashboard/frontend/cypress/e2e/orchestrator/workflow/08-hosts.e2e-spec.ts @@ -29,6 +29,7 @@ describe('Host Page', () => { it('should create rgw services', () => { services.navigateTo('create'); services.addService('rgw', false, 4); + services.navigateTo('index'); services.checkExist('rgw.foo', true); }); diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/cluster.module.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/cluster.module.ts index b2ffca0a87813..b05b726e5f600 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/cluster.module.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/cluster.module.ts @@ -19,7 +19,9 @@ import { IconService, TagModule, SelectModule, - LayoutModule + LayoutModule, + NumberModule, + FileUploaderModule } from 'carbon-components-angular'; import Analytics from '@carbon/icons/es/analytics/16'; import CloseFilled from '@carbon/icons/es/close--filled/16'; @@ -124,7 +126,9 @@ import { TextLabelListComponent } from '~/app/shared/components/text-label-list/ TagModule, TextLabelListComponent, SelectModule, - LayoutModule + LayoutModule, + NumberModule, + FileUploaderModule ], declarations: [ MonitorComponent, diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/services/service-form/service-form.component.html b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/services/service-form/service-form.component.html index 4b5dd8b330183..8a158c229f441 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/services/service-form/service-form.component.html +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/services/service-form/service-form.component.html @@ -1,1211 +1,1214 @@ - - {{ action | titlecase }} {{ resource | upperFirst }} - + + +

{{ action | titlecase }} {{ resource }}

+ +
+
- + } - - -
- -
- - - An RBD application-enabled pool in which the gateway configuration can be managed. - - This field is required. -
-
+ @if (rbdPools === null) { + + } + @if (rbdPools && rbdPools.length === 0) { + + } + @if (rbdPools && rbdPools.length > 0) { + + } + @for (pool of rbdPools; track pool) { + + } + + - -
- -
-
- -
- - The name of the gateway group. - - This field is required. -
-
+ @if (serviceForm.showError('pool', frm, 'required')) { + This field is required. + } +
+ + } - -
- -
-
- {{serviceForm.controls.service_type.value}}. - - + @if (serviceForm.controls.service_type.value === 'nvmeof') { +
+ + + + + @if (serviceForm.showError('service_id', frm, 'required')) { + This field is required. + } + +
+ } + + + @if (serviceForm.controls.service_type.value !== 'snmp-gateway') { +
+ @if (isPrefixedNamedService) { +
+
+ + -
- This field is required. - This service id is already in use. - MDS service id must start with a letter and contain alphanumeric characters or '.', '-', and '_' + [value]="serviceForm.controls.service_type.value + '.'" + readonly="true"> +
+ + + ​ + +
+ } @else { + + Service name + + + } -
- -
- -
-
+ + @if (serviceForm.showError('service_id', frm, 'required')) { + This field is required. + } + @if (serviceForm.showError('service_id', frm, 'uniqueName')) { + This service id is already in use. + } + @if (serviceForm.showError('service_id', frm, 'mdsPattern')) { + MDS service id must start with a letter and contain alphanumeric characters or '.', '-', and + '_' + } + +
+ } -
- -
- -
-
+ label="Zonegroup" + [disabled]="zonegroupList.length === 0 || editing ? true : null"> + @for (zonegroup of zonegroupList; track zonegroup) { + + } + +
-
- -
- -
-
+ label="Zone" + [disabled]="zoneList.length === 0 || editing ? true : null"> + @for (zone of zoneList; track zone) { + + } + +
+ } - -
-
-
- - - If Unmanaged is selected, the orchestrator will not start or stop any daemons associated with this service. Placement and all other properties will be ignored. -
-
-
+ +
+
+ + + Enable + + If Unmanaged is selected, the orchestrator will not start or stop any daemons associated with this + service. + Placement and all other properties will be ignored. + + +
+
- -
- -
- -
-
- - -
- -
- - This field is required. -
-
- - -
- -
- - -
-
- - -
- -
- - Number of deamons that will be deployed - The value must be at least 1. - The entered value needs to be a number. -
-
- - - - -
- -
- - The entered value needs to be a number. - The value must be at least 1. - The value cannot exceed 65535. -
-
-
+ + + + + } - - -
- -
- - This field is required. -
-
+ + @if (hostsAndLabels$ | async; as data) { + @if (!serviceForm.controls.unmanaged.value && serviceForm.controls.placement.value === 'label') { +
+ + + + + @if (serviceForm.showError('label', frm, 'required')) { + This field is required. + } + +
+ } + + @if (!serviceForm.controls.unmanaged.value && serviceForm.controls.placement.value === 'hosts') { +
+ + + +
+ } + } - - - -
- -
- -
-
- -
- -
- - The entered value needs to be a number. - The value must be at least 1. - The value cannot exceed 65535. -
-
+ + @if (!serviceForm.controls.unmanaged.value && serviceForm.controls.service_type.value !== 'nvmeof') { +
+ + +
+ } - -
- -
- - This field is required. -
-
+ + @if (!serviceForm.controls.unmanaged.value && serviceForm.controls.service_type.value === 'rgw') { + +
+ + + + @if (serviceForm.showError('rgw_frontend_port', frm, 'min')) { + The value must be at least 1. + } + @if (serviceForm.showError('rgw_frontend_port', frm, 'max')) { + The value cannot exceed 65535. + } + +
+ } - -
- -
-
- - - - - This field is required. -
-
-
-
+ + + @if (serviceForm.controls.service_type.value === 'iscsi') { +
+ + @if (pools === null) { + + } + @if (pools && pools.length === 0) { + + } + @if (pools && pools.length > 0) { + + } + @for (pool of pools; track pool) { + + } + + + @if (serviceForm.showError('pool', frm, 'required')) { + This field is required. + } + +
+ } - - -
- -
- - This field is required. -
-
+ + @if (!serviceForm.controls.unmanaged.value && serviceForm.controls.service_type.value === 'iscsi') { + +
+ + +
+ +
+ + + + @if (serviceForm.showError('api_port', frm, 'min')) { + The value must be at least 1. + } + @if (serviceForm.showError('api_port', frm, 'max')) { + The value cannot exceed 65535. + } + +
+ +
+ + + + + @if (serviceForm.showError('api_user', frm, 'required')) { + This field is required. + } + +
-
- -
- - This field is required. - The value must start with either 'http:', 'https:', 'rados:' or 'rados:mon-config-key:' -
-
+ +
+ + + + + @if (serviceForm.showError('api_password', frm, 'required')) { + This field is required. + } + +
+ } -
- -
-
- - -
-
-
+ + @if (serviceForm.controls.service_type.value === 'smb') { +
+ + + + + @if (serviceForm.showError('cluster_id', frm, 'required')) { + This field is required. + } + +
-
- - -
+
+ + + + + @if (serviceForm.showError('config_uri', frm, 'required')) { + This field is required. + } + @if (serviceForm.showError('config_uri', frm, 'configUriPattern')) { + The value must start with either 'http:', 'https:', 'rados:' or 'rados:mon-config-key:' + } + +
+
+
+ + @for (feature of smbFeaturesList; track feature) { + + Enable + + } +
+
-
- -
- -
-
+
+ + +
-
- -
- -
-
+
+ + +
-
- -
- -
-
+
+ + +
-
+
+ + +
+ } - - - -
- -
- - This field is required. -
-
+ + @if (serviceForm.controls.service_type.value === 'ingress') { + +
+ + + + + @if (serviceForm.showError('virtual_ip', frm, 'required')) { + This field is required. + } + +
+ +
+ + + + @if (serviceForm.showError('frontend_port', frm, 'required')) { + This field is required. + } + @if (serviceForm.showError('frontend_port', frm, 'min')) { + The value must be at least 1. + } + @if (serviceForm.showError('frontend_port', frm, 'max')) { + The value cannot exceed 65535. + } + +
+ +
+ + + + @if (serviceForm.showError('monitor_port', frm, 'required')) { + This field is required. + } + @if (serviceForm.showError('monitor_port', frm, 'min')) { + The value must be at least 1. + } + @if (serviceForm.showError('monitor_port', frm, 'max')) { + The value cannot exceed 65535. + } + +
+ + @if (!serviceForm.controls.unmanaged.value) { +
+ + +
+ } + } - -
- -
- - The entered value needs to be a number. - The value must be at least 1. - The value cannot exceed 65535. - This field is required. -
-
+ + @if (serviceForm.controls.service_type.value === 'snmp-gateway') { + +
+ + + @for (snmpVersion of ['V2c', 'V3']; track snmpVersion) { + + } + + + @if (serviceForm.showError('snmp_version', frm, 'required')) { + This field is required. + } + +
+ +
+ + + + @if (serviceForm.showError('snmp_destination', frm, 'required')) { + This field is required. + } + @if (serviceForm.showError('snmp_destination', frm, 'snmpDestinationPattern')) { + The value does not match the pattern: hostname:port + } + + +
+ + @if (serviceForm.controls.snmp_version.value === 'V3') { +
+ + + + + @if (serviceForm.showError('engine_id', frm, 'required')) { + This field is required. + } + @if (serviceForm.showError('engine_id', frm, 'snmpEngineIdPattern')) { + The value does not match the pattern: Must be in hexadecimal and length must be multiple of + 2 + with min value = 10 and max value = 64. + } + +
+ +
+ + + @for (authProtocol of ['SHA', 'MD5']; track authProtocol) { + + } + + + @if (serviceForm.showError('auth_protocol', frm, 'required')) { + This field is required. + } + +
- -
- -
- - The entered value needs to be a number. - The value must be at least 1. - The value cannot exceed 65535. - This field is required. -
-
- -
- -
- -
-
-
+ +
+ + + @for (privacyProtocol of ['DES', 'AES']; track privacyProtocol) { + + } + +
+ } + + @if (['V2c', 'V3'].includes(serviceForm.controls.snmp_version?.value)) { +
+ Credentials + + @if (serviceForm.controls.snmp_version.value === 'V2c') { +
+ + + + + @if (serviceForm.showError('snmp_community', frm, 'required')) { + This field is required. + } + +
+ } + + @if (serviceForm.controls.snmp_version.value === 'V3') { +
+ + + + + @if (serviceForm.showError('snmp_v3_auth_username', frm, 'required')) { + This field is required. + } + +
+ } + + @if (serviceForm.controls.snmp_version.value === 'V3') { +
+ + + + + @if (serviceForm.showError('snmp_v3_auth_password', frm, 'required')) { + This field is required. + } + +
+ } + + @if (serviceForm.controls.snmp_version.value === 'V3' && serviceForm.controls.privacy_protocol.value !== null && + serviceForm.controls.privacy_protocol.value !== undefined) { +
+ + + + + @if (serviceForm.showError('snmp_v3_priv_password', frm, 'required')) { + This field is required. + } + +
+ } +
+ } + } - - - -
- -
- - This field is required. -
-
- -
- -
- - This field is required. - The value does not match the pattern: hostname:port -
-
- -
- -
- - This field is required. - The value does not match the pattern: Must be in hexadecimal and length must be multiple of 2 with min value = 10 amd max value = 64. -
-
- -
- -
- - This field is required. -
-
- -
- -
- -
-
- -
- Credentials - -
- -
- - This field is required. -
-
- -
- -
- - This field is required. -
-
- -
- -
- - This field is required. -
-
- -
- -
- - This field is required. -
-
-
-
+ + @if (serviceForm.controls.service_type.value === 'oauth2-proxy') { + +
+ + + + + @if (serviceForm.showError('provider_display_name', frm, 'required')) { + This field is required. + } + +
+ +
+ + + + + @if (serviceForm.showError('client_id', frm, 'required')) { + This field is required. + } + +
+ +
+ + + + + + + @if (serviceForm.showError('client_secret', frm, 'required')) { + This field is required. + } + +
+ +
+ + + + + @if (serviceForm.showError('oidc_issuer_url', frm, 'required')) { + This field is required. + } + @if (serviceForm.showError('oidc_issuer_url', frm, 'validUrl')) { + Invalid url. + } + +
+ +
+ + + + + @if (serviceForm.showError('https_address', frm, 'invalidAddress')) { + Format must be [IP|Hostname]:port and the port between 0 and 65535 + } + +
+ +
+ + + + + @if (serviceForm.showError('redirect_url', frm, 'required')) { + This field is required. + } + +
+ +
+ + +
+ } - - - -
- -
- - The display name for the identity provider (IdP) in the UI. - This field is required. -
-
- -
- -
- - The client ID for authenticating with the IdP. - This field is required. -
-
- -
- -
-
- - - - - - -
- The client secret for authenticating with the IdP. - This field is required. -
-
- -
- -
- - The URL of the OpenID Connect (OIDC) issuer. - This field is required. - Invalid url. -
-
- -
- -
- - The address for HTTPS connections as [IP|Hostname]:port. - Format must be [IP|Hostname]:port and the port between 0 and 65535 -
-
- -
- -
- - The URL the oauth2-proxy service will redirect to after a successful login. -
-
- -
- -
- - Comma separated list of domains to be allowed to redirect to, used for login or logout. -
-
-
+ @if (!serviceForm.controls.unmanaged.value && ['mgmt-gateway'].includes(serviceForm.controls.service_type.value)) + { + +
+ + + + @if (serviceForm.showError('port', frm, 'min')) { + The value must be at least 1. + } + @if (serviceForm.showError('port', frm, 'max')) { + The value cannot exceed 65535. + } + +
+ +
+
+ + + Enable + + Allows to enable authentication through an external Identity Provider (IdP) using Single Sign-On (SSO) + + +
+
+ +
+ + + +
+ +
+ + SSL ciphers + + @if (serviceForm.showError('ssl_ciphers', frm, 'invalidPattern')) { + Invalid cipher suite. Each cipher must be separated by '-' and each cipher suite must be separated + by + ':' + } + +
+ } + + @if (!serviceForm.controls.unmanaged.value && ['rgw', 'iscsi', 'ingress', 'oauth2-proxy', + 'mgmt-gateway'].includes(serviceForm.controls.service_type.value)) { + + @if (!['mgmt-gateway'].includes(serviceForm.controls.service_type.value)) { +
+ SSL + +
+ } + + @if (serviceForm.controls.ssl.value || ['mgmt-gateway'].includes(serviceForm.controls.service_type.value)) { +
+ Certificate +
+ - - -
- -
- - The entered value needs to be a number. - The value must be at least 1. - The value cannot exceed 65535. -
+
- -
-
- - - Enable - - Allows to enable authentication through an external Identity Provider (IdP) using Single Sign-On (SSO) - - -
-
- -
- - - -
- -
- + + + @if (serviceForm.showError('ssl_cert', frm, 'required')) { + This field is required. + } + @if (serviceForm.showError('ssl_cert', frm, 'pattern')) { + Invalid SSL certificate. + } + +
+ } + + @if ((serviceForm.controls.ssl.value && !(['rgw', 'ingress'].includes(serviceForm.controls.service_type.value))) + || ['mgmt-gateway'].includes(serviceForm.controls.service_type.value)) { +
+ Private key
-
- -
- Default cipher list used: https://ssl-config.mozilla.org/#server=nginx - Invalid cipher suite. Each cipher must be separated by '-' and each cipher suite must be separated by ':' -
-
-
- - - - -
-
-
- - -
-
-
-
- -
- -
- - - This field is required. - Invalid SSL certificate. -
-
+ - -
- -
- - - This field is required. - Invalid SSL private key. -
+
-
+ + + @if (serviceForm.showError('ssl_key', frm, 'required')) { + This field is required. + } + @if (serviceForm.showError('ssl_key', frm, 'pattern')) { + Invalid SSL private key. + } + +
+ } - @if(serviceForm.controls.service_type.value === 'rgw') { + @if(serviceForm.controls.service_type.value === 'rgw') {
} + } + + @if (serviceForm.controls.service_type.value === 'grafana') { +
+ + + + @if (serviceForm.showError('grafana_port', frm, 'required')) { + This field is required. + } + @if (serviceForm.showError('grafana_port', frm, 'min')) { + The value must be at least 1. + } + @if (serviceForm.showError('grafana_port', frm, 'max')) { + The value cannot exceed 65535. + } + +
+
+ + Grafana password + + + + +
+ } - - -
- -
- - The entered value needs to be a number. - The value must be at least 1. - The value cannot exceed 65535. - This field is required. -
-
- -
- -
-
- - - - - - -
-
-
-
- - Modifying the default settings could lead to a weaker security configuration + } - - -
-
-
- - - Enables mutual TLS (mTLS) between the client and the gateway server. -
-
-
+ + + @if (serviceForm.controls.service_type.value === 'nvmeof') { +
+
+ + + Enable + + Enables mutual TLS (mTLS) between the client and the gateway server. + + +
+
+ @if (serviceForm.controls.enable_mtls.value) { + +
+ Root CA certificate + - -
- -
- - - This field is required. -
-
+ +
+ + @if (serviceForm.showError('root_ca_cert', frm, 'required')) { + This field is required. + } + +
- -
- -
- - - This field is required. -
-
+ +
+ Client CA certificate + + + + + @if (serviceForm.showError('client_cert', frm, 'required')) { + This field is required. + } + +
- -
- -
- - - This field is required. -
-
+ +
+ Client key + - -
- -
- - - This field is required. -
-
+ +
+ + @if (serviceForm.showError('client_key', frm, 'required')) { + This field is required. + } + +
- -
- -
- - - This field is required. -
-
+ +
+ Gateway server certificate + + + + + @if (serviceForm.showError('server_cert', frm, 'required')) { + This field is required. + } +
-
+ +
+ + + + Default cipher list used: https://ssl-config.mozilla.org/#server=nginx + + diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/services/service-form/service-form.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/services/service-form/service-form.component.spec.ts index 33fdca70b6d14..03628af822622 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/services/service-form/service-form.component.spec.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/services/service-form/service-form.component.spec.ts @@ -17,7 +17,13 @@ import { configureTestBed, FormHelper, Mocks } from '~/testing/unit-test-helper' import { ServiceFormComponent } from './service-form.component'; import { PoolService } from '~/app/shared/api/pool.service'; import { USER } from '~/app/shared/constants/app.constants'; -import { SelectModule } from 'carbon-components-angular'; +import { + CheckboxModule, + InputModule, + ModalModule, + NumberModule, + SelectModule +} from 'carbon-components-angular'; // for 'nvmeof' service const mockPools = [ @@ -47,8 +53,12 @@ describe('ServiceFormComponent', () => { ReactiveFormsModule, RouterTestingModule, SharedModule, + ToastrModule.forRoot(), + InputModule, SelectModule, - ToastrModule.forRoot() + NumberModule, + ModalModule, + CheckboxModule ] }); @@ -74,7 +84,11 @@ describe('ServiceFormComponent', () => { it('should test placement (host)', () => { formHelper.setValue('service_type', 'crash'); formHelper.setValue('placement', 'hosts'); - formHelper.setValue('hosts', ['mgr0', 'mon0', 'osd0']); + formHelper.setValue('hosts', [ + { content: 'mgr0', selected: true }, + { content: 'mon0', selected: true }, + { content: 'osd0', selected: true } + ]); formHelper.setValue('count', 2); component.onSubmit(); expect(cephServiceService.create).toHaveBeenCalledWith({ @@ -90,12 +104,12 @@ describe('ServiceFormComponent', () => { it('should test placement (label)', () => { formHelper.setValue('service_type', 'mgr'); formHelper.setValue('placement', 'label'); - formHelper.setValue('label', 'foo'); + formHelper.setValue('label', [{ content: 'foo', selected: true }]); component.onSubmit(); expect(cephServiceService.create).toHaveBeenCalledWith({ service_type: 'mgr', placement: { - label: 'foo' + label: ['foo'] }, unmanaged: false }); @@ -352,7 +366,7 @@ x4Ea7kGVgx9kWh5XjWz9wjZvY49UKIT5ppIAWPMbLl3UpfckiuNhTA== it('should submit iscsi with trusted ips', () => { formHelper.setValue('ssl', true); - formHelper.setValue('trusted_ip_list', ' 172.16.0.5, 192.1.1.10 '); + formHelper.setValue('trusted_ip_list', [' 172.16.0.5', '192.1.1.10 ']); component.onSubmit(); expect(cephServiceService.create).toHaveBeenCalledWith({ service_type: 'iscsi', @@ -364,7 +378,7 @@ x4Ea7kGVgx9kWh5XjWz9wjZvY49UKIT5ppIAWPMbLl3UpfckiuNhTA== api_secure: true, ssl_cert: '', ssl_key: '', - trusted_ip_list: '172.16.0.5, 192.1.1.10' + trusted_ip_list: ['172.16.0.5', '192.1.1.10'] }); }); @@ -397,13 +411,13 @@ x4Ea7kGVgx9kWh5XjWz9wjZvY49UKIT5ppIAWPMbLl3UpfckiuNhTA== it('should submit invalid iscsi port (1)', () => { formHelper.setValue('api_port', 0); - fixture.detectChanges(); + component.onSubmit(); formHelper.expectError('api_port', 'min'); }); it('should submit invalid iscsi port (2)', () => { formHelper.setValue('api_port', 65536); - fixture.detectChanges(); + component.onSubmit(); formHelper.expectError('api_port', 'max'); }); @@ -459,7 +473,7 @@ x4Ea7kGVgx9kWh5XjWz9wjZvY49UKIT5ppIAWPMbLl3UpfckiuNhTA== expect(component.serviceForm.get('pool')?.value).toBe('rbd'); const poolInput = fixture.debugElement.query(By.css('#pool')).nativeElement; // Simulate input value change - poolInput.value = 'pool-2'; + form.get('pool').setValue('pool-2'); // Trigger the input event poolInput.dispatchEvent(new Event('input')); // Trigger the change event @@ -546,6 +560,7 @@ x4Ea7kGVgx9kWh5XjWz9wjZvY49UKIT5ppIAWPMbLl3UpfckiuNhTA== formHelper.setValue('service_id', 'foo'); formHelper.setValue('cluster_id', 'cluster_foo'); formHelper.setValue('config_uri', 'rados://.smb/foo/scc.toml'); + formHelper.setValue('custom_dns', [' 192.168.76.204', '192.168.76.205 ']); }); it('should submit smb', () => { @@ -557,9 +572,7 @@ x4Ea7kGVgx9kWh5XjWz9wjZvY49UKIT5ppIAWPMbLl3UpfckiuNhTA== service_id: 'foo', cluster_id: 'cluster_foo', config_uri: 'rados://.smb/foo/scc.toml', - custom_dns: null, - join_sources: undefined, - user_sources: undefined + custom_dns: ['192.168.76.204', '192.168.76.205'] }); }); }); @@ -581,7 +594,6 @@ x4Ea7kGVgx9kWh5XjWz9wjZvY49UKIT5ppIAWPMbLl3UpfckiuNhTA== backend_service: 'rgw.foo', service_id: 'rgw.foo', virtual_ip: '192.168.20.1/24', - virtual_interface_networks: null, ssl: false }); }); @@ -596,14 +608,14 @@ x4Ea7kGVgx9kWh5XjWz9wjZvY49UKIT5ppIAWPMbLl3UpfckiuNhTA== // min value formHelper.setValue('frontend_port', 1); formHelper.setValue('monitor_port', 1); - fixture.detectChanges(); + component.onSubmit(); formHelper.expectValid('frontend_port'); formHelper.expectValid('monitor_port'); // max value formHelper.setValue('frontend_port', 65535); formHelper.setValue('monitor_port', 65535); - fixture.detectChanges(); + component.onSubmit(); formHelper.expectValid('frontend_port'); formHelper.expectValid('monitor_port'); }); @@ -612,14 +624,14 @@ x4Ea7kGVgx9kWh5XjWz9wjZvY49UKIT5ppIAWPMbLl3UpfckiuNhTA== // min formHelper.setValue('frontend_port', 0); formHelper.setValue('monitor_port', 0); - fixture.detectChanges(); + component.onSubmit(); formHelper.expectError('frontend_port', 'min'); formHelper.expectError('monitor_port', 'min'); // max formHelper.setValue('frontend_port', 65536); formHelper.setValue('monitor_port', 65536); - fixture.detectChanges(); + component.onSubmit(); formHelper.expectError('frontend_port', 'max'); formHelper.expectError('monitor_port', 'max'); @@ -633,7 +645,7 @@ x4Ea7kGVgx9kWh5XjWz9wjZvY49UKIT5ppIAWPMbLl3UpfckiuNhTA== it('should not show private key field with ssl enabled', () => { formHelper.setValue('ssl', true); - fixture.detectChanges(); + component.onSubmit(); const ssl_key = fixture.debugElement.query(By.css('#ssl_key')); expect(ssl_key).toBeNull(); }); @@ -651,7 +663,7 @@ x4Ea7kGVgx9kWh5XjWz9wjZvY49UKIT5ppIAWPMbLl3UpfckiuNhTA== -----END RSA PRIVATE KEY-----`; formHelper.setValue('ssl', true); formHelper.setValue('ssl_cert', pemCert); - fixture.detectChanges(); + component.onSubmit(); formHelper.expectValid('ssl_cert'); }); }); @@ -759,8 +771,8 @@ x4Ea7kGVgx9kWh5XjWz9wjZvY49UKIT5ppIAWPMbLl3UpfckiuNhTA== component.ngOnInit(); expect(cephServiceSpy).toBeCalledTimes(2); expect(component.action).toBe('Edit'); - const serviceType = fixture.debugElement.query(By.css('#service_type')).nativeElement; - const serviceId = fixture.debugElement.query(By.css('#service_id')).nativeElement; + const serviceType = fixture.componentInstance.serviceForm.get('service_type'); + const serviceId = fixture.componentInstance.serviceForm.get('service_id'); expect(serviceType.disabled).toBeTruthy(); expect(serviceId.disabled).toBeTruthy(); }); @@ -770,7 +782,7 @@ x4Ea7kGVgx9kWh5XjWz9wjZvY49UKIT5ppIAWPMbLl3UpfckiuNhTA== formHelper.setValue('service_type', 'nvmeof'); component.ngOnInit(); fixture.detectChanges(); - const poolId = fixture.debugElement.query(By.css('#pool')).nativeElement; + const poolId = fixture.componentInstance.serviceForm.get('pool'); expect(poolId.disabled).toBeTruthy(); }); diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/services/service-form/service-form.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/services/service-form/service-form.component.ts index ba3d476a432bd..4943ab6651cf0 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/services/service-form/service-form.component.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/services/service-form/service-form.component.ts @@ -1,13 +1,14 @@ +import { Location } from '@angular/common'; import { HttpParams } from '@angular/common/http'; import { Component, Input, OnInit, ViewChild } from '@angular/core'; import { AbstractControl, UntypedFormControl, Validators } from '@angular/forms'; import { ActivatedRoute, Router } from '@angular/router'; -import { NgbActiveModal, NgbTypeahead } from '@ng-bootstrap/ng-bootstrap'; +import { NgbTypeahead } from '@ng-bootstrap/ng-bootstrap'; import { ListItem } from 'carbon-components-angular'; import _ from 'lodash'; -import { forkJoin, merge, Observable, Subject, Subscription } from 'rxjs'; -import { debounceTime, distinctUntilChanged, filter, map } from 'rxjs/operators'; +import { forkJoin, Observable, Subject, Subscription } from 'rxjs'; +import { map } from 'rxjs/operators'; import { Pool } from '~/app/ceph/pool/pool'; import { CreateRgwServiceEntitiesComponent } from '~/app/ceph/rgw/create-rgw-service-entities/create-rgw-service-entities.component'; import { RgwRealm, RgwZonegroup, RgwZone, RgwEntities } from '~/app/ceph/rgw/models/rgw-multisite'; @@ -20,8 +21,6 @@ import { RgwMultisiteService } from '~/app/shared/api/rgw-multisite.service'; import { RgwRealmService } from '~/app/shared/api/rgw-realm.service'; import { RgwZoneService } from '~/app/shared/api/rgw-zone.service'; import { RgwZonegroupService } from '~/app/shared/api/rgw-zonegroup.service'; -import { SelectMessages } from '~/app/shared/components/select/select-messages.model'; -import { SelectOption } from '~/app/shared/components/select/select-option.model'; import { ActionLabelsI18n, TimerServiceInterval, @@ -73,7 +72,8 @@ export class ServiceFormComponent extends CdForm implements OnInit { resource: string; serviceTypes: string[] = []; serviceIds: string[] = []; - hosts: any; + selectedLabels: string[] = []; + selectedHosts: string[] = []; labels: string[]; labelClick = new Subject(); labelFocus = new Subject(); @@ -112,6 +112,8 @@ export class ServiceFormComponent extends CdForm implements OnInit { { value: QatOptions.sw, label: 'Software' }, { value: QatOptions.none, label: 'None' } ]; + open: boolean = false; + hostsAndLabels$: Observable<{ hosts: { content: string }[]; labels: { content: string }[] }>; constructor( public actionLabels: ActionLabelsI18n, @@ -129,18 +131,11 @@ export class ServiceFormComponent extends CdForm implements OnInit { public rgwZoneService: RgwZoneService, public rgwMultisiteService: RgwMultisiteService, private route: ActivatedRoute, - public activeModal: NgbActiveModal, - public modalService: ModalCdsService + public modalService: ModalCdsService, + private location: Location ) { super(); this.resource = $localize`service`; - this.hosts = { - options: [], - messages: new SelectMessages({ - empty: $localize`There are no hosts.`, - filter: $localize`Filter hosts` - }) - }; this.createForm(); } @@ -202,7 +197,7 @@ export class ServiceFormComponent extends CdForm implements OnInit { ] ], hosts: [[]], - count: [null, [CdValidators.number(false)]], + count: [null, [CdValidators.number(false), Validators.min(1)]], unmanaged: [false], // iSCSI // NVMe/TCP @@ -285,7 +280,10 @@ export class ServiceFormComponent extends CdForm implements OnInit { ] ], // RGW - rgw_frontend_port: [null, [CdValidators.number(false)]], + rgw_frontend_port: [ + null, + [CdValidators.number(false), Validators.min(1), Validators.max(65535)] + ], realm_name: [null], zonegroup_name: [null], zone_name: [null], @@ -294,7 +292,7 @@ export class ServiceFormComponent extends CdForm implements OnInit { }), // iSCSI trusted_ip_list: [null], - api_port: [null, [CdValidators.number(false)]], + api_port: [null, [CdValidators.number(false), Validators.min(1), Validators.max(65535)]], api_user: [ null, [ @@ -374,7 +372,9 @@ export class ServiceFormComponent extends CdForm implements OnInit { CdValidators.number(false), CdValidators.requiredIf({ service_type: 'ingress' - }) + }), + Validators.min(1), + Validators.max(65535) ] ], monitor_port: [ @@ -383,7 +383,9 @@ export class ServiceFormComponent extends CdForm implements OnInit { CdValidators.number(false), CdValidators.requiredIf({ service_type: 'ingress' - }) + }), + Validators.min(1), + Validators.max(65535) ] ], virtual_interface_networks: [null], @@ -507,7 +509,7 @@ export class ServiceFormComponent extends CdForm implements OnInit { null, [ CdValidators.requiredIf({ - service_type: 'snmp-gateway' + snmp_version: 'V3' }), CdValidators.custom('snmpEngineIdPattern', (value: string) => { if (_.isEmpty(value)) { @@ -521,7 +523,7 @@ export class ServiceFormComponent extends CdForm implements OnInit { 'SHA', [ CdValidators.requiredIf({ - service_type: 'snmp-gateway' + snmp_version: 'V3' }) ] ], @@ -538,7 +540,7 @@ export class ServiceFormComponent extends CdForm implements OnInit { null, [ CdValidators.requiredIf({ - service_type: 'snmp-gateway' + snmp_version: 'V3' }) ] ], @@ -546,7 +548,7 @@ export class ServiceFormComponent extends CdForm implements OnInit { null, [ CdValidators.requiredIf({ - service_type: 'snmp-gateway' + snmp_version: 'V3' }) ] ], @@ -625,6 +627,7 @@ export class ServiceFormComponent extends CdForm implements OnInit { } ngOnInit(): void { + this.open = true; this.action = this.actionLabels.CREATE; this.resolveRoute(); @@ -646,19 +649,15 @@ export class ServiceFormComponent extends CdForm implements OnInit { this.serviceTypes = _.difference(resp, this.hiddenServices).sort(); }); - this.hostService.getAllHosts().subscribe((resp: Host[]) => { - const options: SelectOption[] = []; - _.forEach(resp, (host: Host) => { - if (_.get(host, 'sources.orchestrator', false)) { - const option = new SelectOption(false, _.get(host, 'hostname'), ''); - options.push(option); - } - }); - this.hosts.options = [...options]; - }); - this.hostService.getLabels().subscribe((resp: string[]) => { - this.labels = resp; - }); + this.hostsAndLabels$ = forkJoin({ + hosts: this.hostService.getAllHosts(), + labels: this.hostService.getLabels() + }).pipe( + map(({ hosts, labels }) => ({ + hosts: hosts.map((host: Host) => ({ content: host['hostname'] })), + labels: labels.map((label: string) => ({ content: label })) + })) + ); this.poolService.getList().subscribe((resp: Pool[]) => { this.pools = resp; this.rbdPools = this.pools.filter(this.rbdService.isRBDPool); @@ -1123,31 +1122,23 @@ export class ServiceFormComponent extends CdForm implements OnInit { } } - searchLabels = (text$: Observable) => { - return merge( - text$.pipe(debounceTime(200), distinctUntilChanged()), - this.labelFocus, - this.labelClick.pipe(filter(() => !this.typeahead.isPopupOpen())) - ).pipe( - map((value) => - this.labels - .filter((label: string) => label.toLowerCase().indexOf(value.toLowerCase()) > -1) - .slice(0, 10) - ) - ); - }; + fileUpload(event: Set, controlName: string) { + const file: File = event?.values()?.next()?.value?.file; + const control: AbstractControl = this.serviceForm.get(controlName); - fileUpload(files: FileList, controlName: string) { - const file: File = files[0]; - const reader = new FileReader(); - reader.addEventListener('load', (event: ProgressEvent) => { - const control: AbstractControl = this.serviceForm.get(controlName); - control.setValue(event.target.result); - control.markAsDirty(); - control.markAsTouched(); - control.updateValueAndValidity(); - }); - reader.readAsText(file, 'utf8'); + if (file) { + const reader = new FileReader(); + reader.addEventListener('load', (event: ProgressEvent) => { + control.setValue(event.target.result); + control.markAsDirty(); + control.markAsTouched(); + control.updateValueAndValidity(); + }); + reader.readAsText(file, 'utf8'); + } else { + // Clicking the "X" on the uploaded file emits an empty event + control.setValue(''); + } } prePopulateId() { @@ -1230,10 +1221,26 @@ export class ServiceFormComponent extends CdForm implements OnInit { (serviceSpec['features'] = serviceSpec['features'] || []).push(feature); } } - serviceSpec['custom_dns'] = values['custom_dns']; - serviceSpec['join_sources'] = values['join_sources']?.trim(); - serviceSpec['user_sources'] = values['user_sources']?.trim(); - serviceSpec['include_ceph_users'] = values['include_ceph_users']?.trim(); + serviceSpec['custom_dns'] = values['custom_dns']?.map((customDns: string) => { + return customDns.trim(); + }); + if (values['join_sources']) { + serviceSpec['join_sources'] = values['join_sources']?.map((joinSource: string) => { + return joinSource.trim(); + }); + } + if (values['user_sources']) { + serviceSpec['user_sources'] = values['user_sources']?.map((userSource: string) => { + return userSource.trim(); + }); + } + if (values['include_ceph_users']) { + serviceSpec['include_ceph_users'] = values['include_ceph_users']?.map( + (includeCephUser: string) => { + return includeCephUser.trim(); + } + ); + } break; case 'snmp-gateway': @@ -1259,11 +1266,15 @@ export class ServiceFormComponent extends CdForm implements OnInit { switch (values['placement']) { case 'hosts': if (values['hosts'].length > 0) { - serviceSpec['placement']['hosts'] = values['hosts']; + serviceSpec['placement']['hosts'] = values['hosts'] + .filter((host: { content: string; selected: boolean }) => host.selected) + .map((host: { content: string }) => host.content); } break; case 'label': - serviceSpec['placement']['label'] = values['label']; + serviceSpec['placement']['label'] = values['label'] + .filter((label: { content: string; selected: boolean }) => label.selected) + .map((label: { content: string }) => label.content); break; } if (_.isNumber(values['count']) && values['count'] > 0) { @@ -1280,8 +1291,10 @@ export class ServiceFormComponent extends CdForm implements OnInit { } break; case 'iscsi': - if (_.isString(values['trusted_ip_list']) && !_.isEmpty(values['trusted_ip_list'])) { - serviceSpec['trusted_ip_list'] = values['trusted_ip_list'].trim(); + if (values['trusted_ip_list']) { + serviceSpec['trusted_ip_list'] = values['trusted_ip_list']?.map((trustedIp: string) => { + return trustedIp.trim(); + }); } if (_.isNumber(values['api_port']) && values['api_port'] > 0) { serviceSpec['api_port'] = values['api_port']; @@ -1300,7 +1313,13 @@ export class ServiceFormComponent extends CdForm implements OnInit { serviceSpec['ssl_cert'] = values['ssl_cert']?.trim(); serviceSpec['ssl_key'] = values['ssl_key']?.trim(); } - serviceSpec['virtual_interface_networks'] = values['virtual_interface_networks']; + if (values['virtual_interface_networks']) { + serviceSpec['virtual_interface_networks'] = values['virtual_interface_networks'] + ?.split(',') + .map((virtualInterfaceNetwork: string) => { + return virtualInterfaceNetwork.trim(); + }); + } break; case 'mgmt-gateway': serviceSpec['ssl_cert'] = values['ssl_cert']?.trim(); @@ -1330,11 +1349,13 @@ export class ServiceFormComponent extends CdForm implements OnInit { serviceSpec['oidc_issuer_url'] = values['oidc_issuer_url']?.trim(); serviceSpec['https_address'] = values['https_address']?.trim(); serviceSpec['redirect_url'] = values['redirect_url']?.trim(); - serviceSpec['allowlist_domains'] = values['allowlist_domains'] - ?.split(',') - .map((domain: string) => { - return domain.trim(); - }); + if (values['allowlist_domains']) { + serviceSpec['allowlist_domains'] = values['allowlist_domains']?.map( + (allowlistDomain: string) => { + return allowlistDomain.trim(); + } + ); + } if (values['ssl']) { serviceSpec['ssl_cert'] = values['ssl_cert']?.trim(); serviceSpec['ssl_key'] = values['ssl_key']?.trim(); @@ -1356,9 +1377,7 @@ export class ServiceFormComponent extends CdForm implements OnInit { self.serviceForm.setErrors({ cdSubmitButton: true }); }, complete: () => { - this.pageURL === 'services' - ? this.router.navigate([this.pageURL, { outlets: { modal: null } }]) - : this.activeModal.close(); + this.closeModal(); } }); } @@ -1387,4 +1406,27 @@ export class ServiceFormComponent extends CdForm implements OnInit { this.setRgwFields(item.realm_name, item.zonegroup_name, item.zone_name); }); } + + multiSelector(event: any, field: 'label' | 'hosts') { + if (field === 'hosts') this.selectedHosts = event.map((host: any) => host.content); + else this.selectedLabels = event.map((label: any) => label.content); + } + + get isPrefixedNamedService(): boolean { + return ( + this.serviceForm.controls.service_type?.value && + ['mds', 'rgw', 'nfs', 'iscsi', 'nvmeof', 'smb', 'ingress'].includes( + this.serviceForm.controls.service_type?.value + ) + ); + } + + closeModal(): void { + if (this.pageURL === 'services') { + this.location.back(); + } else { + this.open = false; + this.modalService.dismissAll(); + } + } } diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/services/services.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/services/services.component.ts index 28476a4148523..7a5e8ee54b75d 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/services/services.component.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/services/services.component.ts @@ -1,7 +1,6 @@ import { Component, Input, OnChanges, OnInit, TemplateRef, ViewChild } from '@angular/core'; import { Router } from '@angular/router'; -import { NgbModalRef } from '@ng-bootstrap/ng-bootstrap'; import { delay } from 'rxjs/operators'; import { CephServiceService } from '~/app/shared/api/ceph-service.service'; @@ -22,7 +21,6 @@ import { Permissions } from '~/app/shared/models/permissions'; import { CephServiceSpec } from '~/app/shared/models/service.interface'; import { RelativeDatePipe } from '~/app/shared/pipes/relative-date.pipe'; import { AuthStorageService } from '~/app/shared/services/auth-storage.service'; -import { ModalService } from '~/app/shared/services/modal.service'; import { TaskWrapperService } from '~/app/shared/services/task-wrapper.service'; import { URLBuilderService } from '~/app/shared/services/url-builder.service'; import { PlacementPipe } from './placement.pipe'; @@ -63,7 +61,6 @@ export class ServicesComponent extends ListWithDetails implements OnChanges, OnI tableActions: CdTableAction[]; showDocPanel = false; count = 0; - bsModalRef: NgbModalRef; orchStatus: OrchestratorStatus; actionOrchFeatures = { @@ -83,7 +80,6 @@ export class ServicesComponent extends ListWithDetails implements OnChanges, OnI constructor( private actionLabels: ActionLabelsI18n, private authStorageService: AuthStorageService, - private modalService: ModalService, private orchService: OrchestratorService, private cephServiceService: CephServiceService, private relativeDatePipe: RelativeDatePipe, @@ -149,7 +145,8 @@ export class ServicesComponent extends ListWithDetails implements OnChanges, OnI hiddenServices: this.hiddenServices, editing: edit }); - this.bsModalRef = this.modalService.show(ServiceFormComponent, initialState, { size: 'lg' }); + let modalRef = this.cdsModalService.show(ServiceFormComponent); + Object.assign(modalRef, initialState); } } diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/forms/cd-validators.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/forms/cd-validators.ts index 4cf707434ff31..362e0e260bf89 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/shared/forms/cd-validators.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/forms/cd-validators.ts @@ -669,7 +669,8 @@ export class CdValidators { } static oauthAddressTest(): ValidatorFn { - const OAUTH2_HTTPS_ADDRESS_PATTERN = /^((\d{1,3}\.){3}\d{1,3}|([a-zA-Z0-9-_]+\.)*[a-zA-Z0-9-_]+)/; + // Pattern matches: IPv4 addresses or hostnames (with or without dots, like 'localhost') + const OAUTH2_HTTPS_ADDRESS_PATTERN = /^((\d{1,3}\.){3}\d{1,3}|([a-zA-Z0-9-_]+\.)*[a-zA-Z0-9-_]+)$/; return (control: AbstractControl): Record | null => { if (!control.value) { return null; @@ -681,7 +682,7 @@ export class CdValidators { const [address, port] = control.value.split(':'); const addressTest = OAUTH2_HTTPS_ADDRESS_PATTERN.test(address); const portTest = Number(port) >= 0 && Number(port) <= 65535; - return { invalidAddress: !(addressTest && portTest) }; + return addressTest && portTest ? null : { invalidAddress: true }; }; } -- 2.47.3