From: Nizamudeen A Date: Sun, 4 Jul 2021 13:16:45 +0000 (+0530) Subject: mgr/dashboard: Cluster Creation Add Host Section and e2es X-Git-Tag: v17.1.0~699^2~5 X-Git-Url: http://git.apps.os.sepia.ceph.com/?a=commitdiff_plain;h=59cbf97e6ceedee0f9d682a94a749fbb01e9f787;p=ceph.git mgr/dashboard: Cluster Creation Add Host Section and e2es Add host section of the cluster creation workflow. 1. Fix bug in the modal where going forward one step on the wizard and coming back opens up the add host modal. 2. Rename Create Cluster to Expand Cluster as per the discussions 3. A skip confirmation modal to warn the user when he tries to skip the cluster creation 4. Adapted all the tests 5. Did some UI improvements like fixing and aligning the styles, colors.. - Used routed modal for host Additon form - Renamed the Create to Add in Host Form Fixes: https://tracker.ceph.com/issues/51517 Fixes: https://tracker.ceph.com/issues/51640 Fixes: https://tracker.ceph.com/issues/50336 Fixes: https://tracker.ceph.com/issues/50565 Signed-off-by: Avan Thakkar Signed-off-by: Aashish Sharma Signed-off-by: Nizamudeen A --- diff --git a/qa/suites/rados/dashboard/tasks/dashboard.yaml b/qa/suites/rados/dashboard/tasks/dashboard.yaml index 1d0ec7395eae3..db28999ece922 100644 --- a/qa/suites/rados/dashboard/tasks/dashboard.yaml +++ b/qa/suites/rados/dashboard/tasks/dashboard.yaml @@ -39,6 +39,7 @@ tasks: - tasks.mgr.dashboard.test_auth - tasks.mgr.dashboard.test_cephfs - tasks.mgr.dashboard.test_cluster_configuration + - tasks.mgr.dashboard.test_cluster - tasks.mgr.dashboard.test_crush_rule - tasks.mgr.dashboard.test_erasure_code_profile - tasks.mgr.dashboard.test_ganesha diff --git a/src/pybind/mgr/dashboard/controllers/host.py b/src/pybind/mgr/dashboard/controllers/host.py index a72c4b34052d5..6ba1a5e0f1cbb 100644 --- a/src/pybind/mgr/dashboard/controllers/host.py +++ b/src/pybind/mgr/dashboard/controllers/host.py @@ -285,7 +285,7 @@ class Host(RESTController): @raise_if_no_orchestrator([OrchFeature.HOST_LIST, OrchFeature.HOST_CREATE]) @handle_orchestrator_error('host') - @host_task('create', {'hostname': '{hostname}'}) + @host_task('add', {'hostname': '{hostname}'}) @EndpointDoc('', parameters={ 'hostname': (str, 'Hostname'), diff --git a/src/pybind/mgr/dashboard/frontend/cypress/integration/cluster/cluster-welcome-page.po.ts b/src/pybind/mgr/dashboard/frontend/cypress/integration/cluster/cluster-welcome-page.po.ts deleted file mode 100644 index 5615b0369d025..0000000000000 --- a/src/pybind/mgr/dashboard/frontend/cypress/integration/cluster/cluster-welcome-page.po.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { PageHelper } from '../page-helper.po'; -import { NotificationSidebarPageHelper } from '../ui/notification.po'; - -export class CreateClusterWelcomePageHelper extends PageHelper { - pages = { - index: { url: '#/create-cluster', id: 'cd-create-cluster' } - }; - - createCluster() { - cy.get('cd-create-cluster').should('contain.text', 'Welcome to Ceph'); - cy.get('[name=create-cluster]').click(); - } - - doSkip() { - cy.get('[name=skip-cluster-creation]').click(); - - cy.get('cd-dashboard').should('exist'); - const notification = new NotificationSidebarPageHelper(); - notification.open(); - notification.getNotifications().should('contain', 'Cluster creation skipped by user'); - } -} diff --git a/src/pybind/mgr/dashboard/frontend/cypress/integration/cluster/create-cluster-review.po.ts b/src/pybind/mgr/dashboard/frontend/cypress/integration/cluster/create-cluster-review.po.ts deleted file mode 100644 index 58844e39afec8..0000000000000 --- a/src/pybind/mgr/dashboard/frontend/cypress/integration/cluster/create-cluster-review.po.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { PageHelper } from '../page-helper.po'; - -export class CreateClusterReviewPageHelper extends PageHelper { - pages = { - index: { url: '#/create-cluster', id: 'cd-create-cluster-review' } - }; - - checkDefaultHostName() { - this.getTableCell(1, 'ceph-node-00.cephlab.com').should('exist'); - } -} diff --git a/src/pybind/mgr/dashboard/frontend/cypress/integration/cluster/create-cluster.po.ts b/src/pybind/mgr/dashboard/frontend/cypress/integration/cluster/create-cluster.po.ts new file mode 100644 index 0000000000000..22941b28e7cb7 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/cypress/integration/cluster/create-cluster.po.ts @@ -0,0 +1,113 @@ +import { PageHelper } from '../page-helper.po'; +import { NotificationSidebarPageHelper } from '../ui/notification.po'; + +const pages = { + index: { url: '#/expand-cluster', id: 'cd-create-cluster' } +}; + +export class CreateClusterWizardHelper extends PageHelper { + pages = pages; + columnIndex = { + hostname: 1, + labels: 2, + status: 3 + }; + + createCluster() { + cy.get('cd-create-cluster').should('contain.text', 'Please expand your cluster first'); + cy.get('[name=expand-cluster]').click(); + } + + doSkip() { + cy.get('[name=skip-cluster-creation]').click(); + cy.contains('cd-modal button', 'Continue').click(); + + cy.get('cd-dashboard').should('exist'); + const notification = new NotificationSidebarPageHelper(); + notification.open(); + notification.getNotifications().should('contain', 'Cluster expansion skipped by user'); + } + + check_for_host() { + this.getTableCount('total').should('not.be.eq', 0); + } + + clickHostTab(hostname: string, tabName: string) { + this.getExpandCollapseElement(hostname).click(); + cy.get('cd-host-details').within(() => { + this.getTab(tabName).click(); + }); + } + + add(hostname: string, exist?: boolean, maintenance?: boolean) { + cy.get('.btn.btn-accent').first().click({ force: true }); + + cy.get('cd-modal').should('exist'); + cy.get('cd-modal').within(() => { + cy.get('#hostname').type(hostname); + if (maintenance) { + cy.get('label[for=maintenance]').click(); + } + if (exist) { + cy.get('#hostname').should('have.class', 'ng-invalid'); + } + cy.get('cd-submit-button').click(); + }); + // back to host list + cy.get(`${this.pages.index.id}`); + } + + checkExist(hostname: string, exist: boolean) { + this.clearTableSearchInput(); + this.getTableCell(this.columnIndex.hostname, hostname).should(($elements) => { + const hosts = $elements.map((_, el) => el.textContent).get(); + if (exist) { + expect(hosts).to.include(hostname); + } else { + expect(hosts).to.not.include(hostname); + } + }); + } + + delete(hostname: string) { + super.delete(hostname, this.columnIndex.hostname); + } + + // Add or remove labels on a host, then verify labels in the table + editLabels(hostname: string, labels: string[], add: boolean) { + this.getTableCell(this.columnIndex.hostname, hostname).click(); + this.clickActionButton('edit'); + + // add or remove label badges + if (add) { + cy.get('cd-modal').find('.select-menu-edit').click(); + for (const label of labels) { + cy.contains('cd-modal .badge', new RegExp(`^${label}$`)).should('not.exist'); + cy.get('.popover-body input').type(`${label}{enter}`); + } + } else { + for (const label of labels) { + cy.contains('cd-modal .badge', new RegExp(`^${label}$`)) + .find('.badge-remove') + .click(); + } + } + cy.get('cd-modal cd-submit-button').click(); + + // Verify labels are added or removed from Labels column + // First find row with hostname, then find labels in the row + this.getTableCell(this.columnIndex.hostname, hostname) + .parent() + .find(`datatable-body-cell:nth-child(${this.columnIndex.labels}) .badge`) + .should(($ele) => { + const newLabels = $ele.toArray().map((v) => v.innerText); + for (const label of labels) { + if (add) { + expect(newLabels).to.include(label); + } else { + expect(newLabels).to.not.include(label); + } + } + }); + } +} diff --git a/src/pybind/mgr/dashboard/frontend/cypress/integration/cluster/hosts.po.ts b/src/pybind/mgr/dashboard/frontend/cypress/integration/cluster/hosts.po.ts index 6752fe9e7870c..7a7e00d6648ac 100644 --- a/src/pybind/mgr/dashboard/frontend/cypress/integration/cluster/hosts.po.ts +++ b/src/pybind/mgr/dashboard/frontend/cypress/integration/cluster/hosts.po.ts @@ -2,7 +2,7 @@ import { PageHelper } from '../page-helper.po'; const pages = { index: { url: '#/hosts', id: 'cd-hosts' }, - create: { url: '#/hosts/create', id: 'cd-host-form' } + add: { url: '#/hosts/(modal:add)', id: 'cd-host-form' } }; export class HostsPageHelper extends PageHelper { @@ -49,21 +49,20 @@ export class HostsPageHelper extends PageHelper { }); } - @PageHelper.restrictTo(pages.create.url) + @PageHelper.restrictTo(pages.add.url) add(hostname: string, exist?: boolean, maintenance?: boolean) { - cy.get(`${this.pages.create.id}`).within(() => { + cy.get(`${this.pages.add.id}`).within(() => { cy.get('#hostname').type(hostname); if (maintenance) { cy.get('label[for=maintenance]').click(); } + if (exist) { + cy.get('#hostname').should('have.class', 'ng-invalid'); + } cy.get('cd-submit-button').click(); }); - if (exist) { - cy.get('#hostname').should('have.class', 'ng-invalid'); - } else { - // back to host list - cy.get(`${this.pages.index.id}`); - } + // back to host list + cy.get(`${this.pages.index.id}`); } @PageHelper.restrictTo(pages.index.url) diff --git a/src/pybind/mgr/dashboard/frontend/cypress/integration/orchestrator/01-hosts.e2e-spec.ts b/src/pybind/mgr/dashboard/frontend/cypress/integration/orchestrator/01-hosts.e2e-spec.ts index cf85642a1b1d2..6c79a74662dff 100644 --- a/src/pybind/mgr/dashboard/frontend/cypress/integration/orchestrator/01-hosts.e2e-spec.ts +++ b/src/pybind/mgr/dashboard/frontend/cypress/integration/orchestrator/01-hosts.e2e-spec.ts @@ -17,7 +17,7 @@ describe('Hosts page', () => { it('should not add an exsiting host', function () { const hostname = Cypress._.sample(this.hosts).name; - hosts.navigateTo('create'); + hosts.navigateTo('add'); hosts.add(hostname, true); }); @@ -26,7 +26,7 @@ describe('Hosts page', () => { hosts.delete(host); // add it back - hosts.navigateTo('create'); + hosts.navigateTo('add'); hosts.add(host); hosts.checkExist(host, true); }); diff --git a/src/pybind/mgr/dashboard/frontend/cypress/integration/orchestrator/06-cluster-welcome-page.e2e-spec.ts b/src/pybind/mgr/dashboard/frontend/cypress/integration/orchestrator/06-cluster-welcome-page.e2e-spec.ts deleted file mode 100644 index bd0470b86700a..0000000000000 --- a/src/pybind/mgr/dashboard/frontend/cypress/integration/orchestrator/06-cluster-welcome-page.e2e-spec.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { CreateClusterWelcomePageHelper } from '../cluster/cluster-welcome-page.po'; - -describe('Create cluster page', () => { - const createCluster = new CreateClusterWelcomePageHelper(); - - beforeEach(() => { - cy.login(); - Cypress.Cookies.preserveOnce('token'); - createCluster.navigateTo(); - }); - - it('should fail to create cluster', () => { - createCluster.createCluster(); - }); - - it('should skip to dashboard landing page', () => { - createCluster.doSkip(); - }); -}); diff --git a/src/pybind/mgr/dashboard/frontend/cypress/integration/orchestrator/workflow/01-create-cluster-welcome-page.e2e-spec.ts b/src/pybind/mgr/dashboard/frontend/cypress/integration/orchestrator/workflow/01-create-cluster-welcome-page.e2e-spec.ts new file mode 100644 index 0000000000000..d776a210a57ee --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/cypress/integration/orchestrator/workflow/01-create-cluster-welcome-page.e2e-spec.ts @@ -0,0 +1,19 @@ +import { CreateClusterWizardHelper } from 'cypress/integration/cluster/create-cluster.po'; + +describe('Create cluster page', () => { + const createCluster = new CreateClusterWizardHelper(); + + beforeEach(() => { + cy.login(); + Cypress.Cookies.preserveOnce('token'); + createCluster.navigateTo(); + }); + + it('should fail to create cluster', () => { + createCluster.createCluster(); + }); + + it('should skip to dashboard landing page', () => { + createCluster.doSkip(); + }); +}); diff --git a/src/pybind/mgr/dashboard/frontend/cypress/integration/orchestrator/workflow/01-hosts.e2e-spec.ts b/src/pybind/mgr/dashboard/frontend/cypress/integration/orchestrator/workflow/01-hosts.e2e-spec.ts index 7dd0c1104334c..e8dea8e30080f 100644 --- a/src/pybind/mgr/dashboard/frontend/cypress/integration/orchestrator/workflow/01-hosts.e2e-spec.ts +++ b/src/pybind/mgr/dashboard/frontend/cypress/integration/orchestrator/workflow/01-hosts.e2e-spec.ts @@ -12,7 +12,7 @@ describe('Hosts page', () => { 'ceph-node-02.cephlab.com' ]; const addHost = (hostname: string, exist?: boolean, maintenance?: boolean) => { - hosts.navigateTo('create'); + hosts.navigateTo('add'); hosts.add(hostname, exist, maintenance); hosts.checkExist(hostname, true); }; @@ -49,7 +49,7 @@ describe('Hosts page', () => { }); it('should not add an existing host', function () { - hosts.navigateTo('create'); + hosts.navigateTo('add'); hosts.add(hostnames[0], true); }); diff --git a/src/pybind/mgr/dashboard/frontend/cypress/integration/orchestrator/workflow/02-create-cluster-add-host.e2e-spec.ts b/src/pybind/mgr/dashboard/frontend/cypress/integration/orchestrator/workflow/02-create-cluster-add-host.e2e-spec.ts new file mode 100644 index 0000000000000..7bf5b5be3d496 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/cypress/integration/orchestrator/workflow/02-create-cluster-add-host.e2e-spec.ts @@ -0,0 +1,48 @@ +import { CreateClusterWizardHelper } from 'cypress/integration/cluster/create-cluster.po'; + +describe('Create cluster add host page', () => { + const createCluster = new CreateClusterWizardHelper(); + const hostnames = [ + 'ceph-node-00.cephlab.com', + 'ceph-node-01.cephlab.com', + 'ceph-node-02.cephlab.com' + ]; + const addHost = (hostname: string, exist?: boolean) => { + createCluster.add(hostname, exist, true); + createCluster.checkExist(hostname, true); + }; + + beforeEach(() => { + cy.login(); + Cypress.Cookies.preserveOnce('token'); + createCluster.navigateTo(); + createCluster.createCluster(); + }); + + it('should check if nav-link and title contains Add Hosts', () => { + cy.get('.nav-link').should('contain.text', 'Add Hosts'); + + cy.get('.title').should('contain.text', 'Add Hosts'); + }); + + it('should check existing host and add new hosts into maintenance mode', () => { + createCluster.checkExist(hostnames[0], true); + + addHost(hostnames[1], false); + addHost(hostnames[2], false); + }); + + it('should not add an existing host', () => { + createCluster.add(hostnames[0], true); + }); + + it('should edit host labels', () => { + const labels = ['foo', 'bar']; + createCluster.editLabels(hostnames[0], labels, true); + createCluster.editLabels(hostnames[0], labels, false); + }); + + it('should delete a host', () => { + createCluster.delete(hostnames[1]); + }); +}); diff --git a/src/pybind/mgr/dashboard/frontend/cypress/integration/orchestrator/workflow/03-create-cluster-review.e2e-spec.ts b/src/pybind/mgr/dashboard/frontend/cypress/integration/orchestrator/workflow/03-create-cluster-review.e2e-spec.ts new file mode 100644 index 0000000000000..17dd84994d60e --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/cypress/integration/orchestrator/workflow/03-create-cluster-review.e2e-spec.ts @@ -0,0 +1,59 @@ +import { CreateClusterWizardHelper } from 'cypress/integration/cluster/create-cluster.po'; + +describe('Create Cluster Review page', () => { + const createCluster = new CreateClusterWizardHelper(); + + beforeEach(() => { + cy.login(); + Cypress.Cookies.preserveOnce('token'); + createCluster.navigateTo(); + createCluster.createCluster(); + + cy.get('button[aria-label="Next"]').click(); + }); + + describe('navigation link and title test', () => { + it('should check if nav-link and title contains Review', () => { + cy.get('.nav-link').should('contain.text', 'Review'); + + cy.get('.title').should('contain.text', 'Review'); + }); + }); + + describe('fields check', () => { + it('should check cluster resources table is present', () => { + // check for table header 'Cluster Resources' + createCluster.getLegends().its(0).should('have.text', 'Cluster Resources'); + + // check for fields in table + createCluster.getStatusTables().should('contain.text', 'Hosts'); + }); + + it('should check Hosts by Label and Host Details tables are present', () => { + // check for there to be two tables + createCluster.getDataTables().should('have.length', 2); + + // check for table header 'Hosts by Label' + createCluster.getLegends().its(1).should('have.text', 'Hosts by Label'); + + // check for table header 'Host Details' + createCluster.getLegends().its(2).should('have.text', 'Host Details'); + + // verify correct columns on Hosts by Label table + createCluster.getDataTableHeaders(0).contains('Label'); + + createCluster.getDataTableHeaders(0).contains('Number of Hosts'); + + // verify correct columns on Host Details table + createCluster.getDataTableHeaders(1).contains('Host Name'); + + createCluster.getDataTableHeaders(1).contains('Labels'); + }); + + it('should check hosts count and default host name are present', () => { + createCluster.getStatusTables().contains(2); + + createCluster.check_for_host(); + }); + }); +}); diff --git a/src/pybind/mgr/dashboard/frontend/cypress/integration/orchestrator/workflow/04-cluster-check.e2e-spec.ts b/src/pybind/mgr/dashboard/frontend/cypress/integration/orchestrator/workflow/04-cluster-check.e2e-spec.ts new file mode 100644 index 0000000000000..9717dd8b7dbf8 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/cypress/integration/orchestrator/workflow/04-cluster-check.e2e-spec.ts @@ -0,0 +1,49 @@ +import { CreateClusterWizardHelper } from 'cypress/integration/cluster/create-cluster.po'; +import { HostsPageHelper } from 'cypress/integration/cluster/hosts.po'; + +describe('when cluster creation is completed', () => { + const createCluster = new CreateClusterWizardHelper(); + + beforeEach(() => { + cy.login(); + Cypress.Cookies.preserveOnce('token'); + }); + + it('should redirect to dashboard landing page after cluster creation', () => { + createCluster.navigateTo(); + createCluster.createCluster(); + + cy.get('button[aria-label="Next"]').click(); + cy.get('button[aria-label="Next"]').click(); + + cy.get('cd-dashboard').should('exist'); + }); + + describe('Hosts page', () => { + const hosts = new HostsPageHelper(); + const hostnames = ['ceph-node-00.cephlab.com', 'ceph-node-02.cephlab.com']; + + beforeEach(() => { + hosts.navigateTo(); + }); + it('should have already exited from maintenance', () => { + for (let host = 0; host < hostnames.length; host++) { + cy.get('datatable-row-wrapper').should('not.have.text', 'maintenance'); + } + }); + + it('should display inventory', () => { + hosts.clickHostTab(hostnames[1], 'Physical Disks'); + cy.get('cd-host-details').within(() => { + hosts.getTableCount('total').should('be.gte', 0); + }); + }); + + it('should display daemons', () => { + hosts.clickHostTab(hostnames[1], 'Daemons'); + cy.get('cd-host-details').within(() => { + hosts.getTableCount('total').should('be.gte', 0); + }); + }); + }); +}); diff --git a/src/pybind/mgr/dashboard/frontend/cypress/integration/orchestrator/workflow/08-create-cluster-review.e2e-spec.ts b/src/pybind/mgr/dashboard/frontend/cypress/integration/orchestrator/workflow/08-create-cluster-review.e2e-spec.ts deleted file mode 100644 index a472810e6e6c5..0000000000000 --- a/src/pybind/mgr/dashboard/frontend/cypress/integration/orchestrator/workflow/08-create-cluster-review.e2e-spec.ts +++ /dev/null @@ -1,61 +0,0 @@ -import { CreateClusterWelcomePageHelper } from 'cypress/integration/cluster/cluster-welcome-page.po'; -import { CreateClusterReviewPageHelper } from 'cypress/integration/cluster/create-cluster-review.po'; - -describe('Create Cluster Review page', () => { - const reviewPage = new CreateClusterReviewPageHelper(); - const createCluster = new CreateClusterWelcomePageHelper(); - - beforeEach(() => { - cy.login(); - Cypress.Cookies.preserveOnce('token'); - createCluster.navigateTo(); - createCluster.createCluster(); - - cy.get('button[aria-label="Next"]').click(); - }); - - describe('navigation link and title test', () => { - it('should check if nav-link and title contains Review', () => { - cy.get('.nav-link').should('contain.text', 'Review'); - - cy.get('.title').should('contain.text', 'Review'); - }); - }); - - describe('fields check', () => { - it('should check cluster resources table is present', () => { - // check for table header 'Status' - reviewPage.getLegends().its(0).should('have.text', 'Cluster Resources'); - - // check for fields in table - reviewPage.getStatusTables().should('contain.text', 'Hosts'); - }); - - it('should check Hosts Per Label and Host Details tables are present', () => { - // check for there to be two tables - reviewPage.getDataTables().should('have.length', 2); - - // check for table header 'Hosts Per Label' - reviewPage.getLegends().its(1).should('have.text', 'Hosts Per Label'); - - // check for table header 'Host Details' - reviewPage.getLegends().its(2).should('have.text', 'Host Details'); - - // verify correct columns on Hosts Per Label table - reviewPage.getDataTableHeaders(0).contains('Label'); - - reviewPage.getDataTableHeaders(0).contains('Number of Hosts'); - - // verify correct columns on Host Details table - reviewPage.getDataTableHeaders(1).contains('Host Name'); - - reviewPage.getDataTableHeaders(1).contains('Labels'); - }); - - it('should check hosts count and default host name are present', () => { - reviewPage.getStatusTables().should('contain.text', '1'); - - reviewPage.checkDefaultHostName(); - }); - }); -}); diff --git a/src/pybind/mgr/dashboard/frontend/src/app/app-routing.module.ts b/src/pybind/mgr/dashboard/frontend/src/app/app-routing.module.ts index 099b31efbda50..bdd2cb8978234 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/app-routing.module.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/app-routing.module.ts @@ -91,7 +91,7 @@ const routes: Routes = [ // Cluster { - path: 'create-cluster', + path: 'expand-cluster', component: CreateClusterComponent, canActivate: [ModuleStatusGuardService], data: { @@ -100,18 +100,18 @@ const routes: Routes = [ redirectTo: 'dashboard', backend: 'cephadm' }, - breadcrumbs: 'Create Cluster' + breadcrumbs: 'Expand Cluster' } }, { path: 'hosts', + component: HostsComponent, data: { breadcrumbs: 'Cluster/Hosts' }, children: [ - { path: '', component: HostsComponent }, { - path: URLVerbs.CREATE, + path: URLVerbs.ADD, component: HostFormComponent, - data: { breadcrumbs: ActionLabels.CREATE } + outlet: 'modal' } ] }, 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 a2c1e6d2f89ec..185c34b27504d 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 @@ -5,6 +5,7 @@ import { RouterModule } from '@angular/router'; import { TreeModule } from '@circlon/angular-tree-component'; import { + NgbActiveModal, NgbDatepickerModule, NgbDropdownModule, NgbNavModule, @@ -106,7 +107,6 @@ import { TelemetryComponent } from './telemetry/telemetry.component'; OsdCreationPreviewModalComponent, RulesListComponent, ActiveAlertListComponent, - HostFormComponent, ServiceDetailsComponent, ServiceDaemonListComponent, TelemetryComponent, @@ -115,6 +115,7 @@ import { TelemetryComponent } from './telemetry/telemetry.component'; OsdFlagsIndivModalComponent, PlacementPipe, CreateClusterComponent - ] + ], + providers: [NgbActiveModal] }) export class ClusterModule {} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/create-cluster/create-cluster.component.html b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/create-cluster/create-cluster.component.html index 661c13fc931c9..fb006ec1beb10 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/create-cluster/create-cluster.component.html +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/create-cluster/create-cluster.component.html @@ -1,4 +1,5 @@ -
+
@@ -10,16 +11,14 @@

Please proceed to complete the cluster creation

-
- + i18n>Expand Cluster
@@ -27,3 +26,48 @@
+ +
+
Expand Cluster
+
+ +
+ +
+

Add Hosts

+
+ +
+
+

Review

+
+

To be implemented

+
+
+
+
+ +
+ + + You are about to skip the cluster expansion process. + You’ll need to navigate through the menu to add hosts and services. + +
Are you sure you want to continue?
+
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/create-cluster/create-cluster.component.scss b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/create-cluster/create-cluster.component.scss index e69de29bb2d1d..580c5219e9f2c 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/create-cluster/create-cluster.component.scss +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/create-cluster/create-cluster.component.scss @@ -0,0 +1,26 @@ +@use './src/styles/vendor/variables' as vv; + +.container-fluid { + align-items: flex-start; + display: flex; + padding-left: 0; + width: 100%; +} + +.card-body { + max-width: 85%; +} + +.vertical-line { + border-left: 1px solid vv.$gray-400; +} + +cd-wizard { + width: 15%; +} + +cd-hosts { + ::ng-deep .nav { + display: none; + } +} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/create-cluster/create-cluster.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/create-cluster/create-cluster.component.spec.ts index 7e061b2e25c9a..1ebdfb3a59d2f 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/create-cluster/create-cluster.component.spec.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/create-cluster/create-cluster.component.spec.ts @@ -5,7 +5,13 @@ import { RouterTestingModule } from '@angular/router/testing'; import { ToastrModule } from 'ngx-toastr'; -import { ClusterService } from '~/app/shared/api/cluster.service'; +import { CephModule } from '~/app/ceph/ceph.module'; +import { CoreModule } from '~/app/core/core.module'; +import { HostService } from '~/app/shared/api/host.service'; +import { ConfirmationModalComponent } from '~/app/shared/components/confirmation-modal/confirmation-modal.component'; +import { AppConstants } from '~/app/shared/constants/app.constants'; +import { ModalService } from '~/app/shared/services/modal.service'; +import { WizardStepsService } from '~/app/shared/services/wizard-steps.service'; import { SharedModule } from '~/app/shared/shared.module'; import { configureTestBed } from '~/testing/unit-test-helper'; import { CreateClusterComponent } from './create-cluster.component'; @@ -13,17 +19,31 @@ import { CreateClusterComponent } from './create-cluster.component'; describe('CreateClusterComponent', () => { let component: CreateClusterComponent; let fixture: ComponentFixture; - let clusterService: ClusterService; + let wizardStepService: WizardStepsService; + let hostService: HostService; + let modalServiceShowSpy: jasmine.Spy; + const projectConstants: typeof AppConstants = AppConstants; configureTestBed({ - declarations: [CreateClusterComponent], - imports: [HttpClientTestingModule, RouterTestingModule, ToastrModule.forRoot(), SharedModule] + imports: [ + HttpClientTestingModule, + RouterTestingModule, + ToastrModule.forRoot(), + SharedModule, + CoreModule, + CephModule + ] }); beforeEach(() => { fixture = TestBed.createComponent(CreateClusterComponent); component = fixture.componentInstance; - clusterService = TestBed.inject(ClusterService); + wizardStepService = TestBed.inject(WizardStepsService); + hostService = TestBed.inject(HostService); + modalServiceShowSpy = spyOn(TestBed.inject(ModalService), 'show').and.returnValue({ + // mock the close function, it might be called if there are async tests. + close: jest.fn() + }); fixture.detectChanges(); }); @@ -31,15 +51,65 @@ describe('CreateClusterComponent', () => { expect(component).toBeTruthy(); }); - it('should have the heading "Welcome to Ceph Dashboard"', () => { + it('should have project name as heading in welcome screen', () => { const heading = fixture.debugElement.query(By.css('h3')).nativeElement; - expect(heading.innerHTML).toBe('Welcome to Ceph Dashboard'); + expect(heading.innerHTML).toBe(`Welcome to ${projectConstants.projectName}`); }); - it('should call updateStatus when cluster creation is skipped', () => { - const clusterServiceSpy = spyOn(clusterService, 'updateStatus').and.callThrough(); - expect(clusterServiceSpy).not.toHaveBeenCalled(); + it('should show confirmation modal when cluster creation is skipped', () => { component.skipClusterCreation(); - expect(clusterServiceSpy).toHaveBeenCalledTimes(1); + expect(modalServiceShowSpy.calls.any()).toBeTruthy(); + expect(modalServiceShowSpy.calls.first().args[0]).toBe(ConfirmationModalComponent); + }); + + it('should show the wizard when cluster creation is started', () => { + component.createCluster(); + fixture.detectChanges(); + const nativeEl = fixture.debugElement.nativeElement; + expect(nativeEl.querySelector('cd-wizard')).not.toBe(null); + }); + + it('should have title Add Hosts', () => { + component.createCluster(); + fixture.detectChanges(); + const heading = fixture.debugElement.query(By.css('.title')).nativeElement; + expect(heading.innerHTML).toBe('Add Hosts'); + }); + + it('should show the host list when cluster creation as first step', () => { + component.createCluster(); + fixture.detectChanges(); + const nativeEl = fixture.debugElement.nativeElement; + expect(nativeEl.querySelector('cd-hosts')).not.toBe(null); + }); + + it('should move to next step and show the second page', () => { + const wizardStepServiceSpy = spyOn(wizardStepService, 'moveToNextStep').and.callThrough(); + const hostServiceSpy = spyOn(hostService, 'list').and.callThrough(); + component.createCluster(); + fixture.detectChanges(); + component.onNextStep(); + fixture.detectChanges(); + const heading = fixture.debugElement.query(By.css('.title')).nativeElement; + expect(wizardStepServiceSpy).toHaveBeenCalledTimes(1); + expect(hostServiceSpy).toBeCalledTimes(1); + expect(heading.innerHTML).toBe('Review'); + }); + + it('should show the button labels correctly', () => { + component.createCluster(); + fixture.detectChanges(); + let submitBtnLabel = component.showSubmitButtonLabel(); + expect(submitBtnLabel).toEqual('Next'); + let cancelBtnLabel = component.showCancelButtonLabel(); + expect(cancelBtnLabel).toEqual('Cancel'); + + // Last page of the wizard + component.onNextStep(); + fixture.detectChanges(); + submitBtnLabel = component.showSubmitButtonLabel(); + expect(submitBtnLabel).toEqual('Expand Cluster'); + cancelBtnLabel = component.showCancelButtonLabel(); + expect(cancelBtnLabel).toEqual('Back'); }); }); diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/create-cluster/create-cluster.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/create-cluster/create-cluster.component.ts index 239a4f13ca7f0..b47a63e8cec00 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/create-cluster/create-cluster.component.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/create-cluster/create-cluster.component.ts @@ -1,44 +1,134 @@ -import { Component } from '@angular/core'; +import { Component, OnDestroy, TemplateRef, ViewChild } from '@angular/core'; +import { Router } from '@angular/router'; + +import { NgbModalRef } from '@ng-bootstrap/ng-bootstrap'; +import { forkJoin, Subscription } from 'rxjs'; +import { finalize } from 'rxjs/operators'; import { ClusterService } from '~/app/shared/api/cluster.service'; -import { AppConstants } from '~/app/shared/constants/app.constants'; +import { HostService } from '~/app/shared/api/host.service'; +import { ConfirmationModalComponent } from '~/app/shared/components/confirmation-modal/confirmation-modal.component'; +import { ActionLabelsI18n, AppConstants } from '~/app/shared/constants/app.constants'; import { NotificationType } from '~/app/shared/enum/notification-type.enum'; -import { Permission } from '~/app/shared/models/permissions'; +import { Permissions } from '~/app/shared/models/permissions'; +import { WizardStepModel } from '~/app/shared/models/wizard-steps'; import { AuthStorageService } from '~/app/shared/services/auth-storage.service'; +import { ModalService } from '~/app/shared/services/modal.service'; import { NotificationService } from '~/app/shared/services/notification.service'; +import { WizardStepsService } from '~/app/shared/services/wizard-steps.service'; @Component({ selector: 'cd-create-cluster', templateUrl: './create-cluster.component.html', styleUrls: ['./create-cluster.component.scss'] }) -export class CreateClusterComponent { - permission: Permission; - orchStatus = false; - featureAvailable = false; +export class CreateClusterComponent implements OnDestroy { + @ViewChild('skipConfirmTpl', { static: true }) + skipConfirmTpl: TemplateRef; + currentStep: WizardStepModel; + currentStepSub: Subscription; + permissions: Permissions; projectConstants: typeof AppConstants = AppConstants; + stepTitles = ['Add Hosts', 'Review']; + startClusterCreation = false; + observables: any = []; + modalRef: NgbModalRef; constructor( private authStorageService: AuthStorageService, + private stepsService: WizardStepsService, + private router: Router, + private hostService: HostService, + private notificationService: NotificationService, + private actionLabels: ActionLabelsI18n, private clusterService: ClusterService, - private notificationService: NotificationService + private modalService: ModalService ) { - this.permission = this.authStorageService.getPermissions().configOpt; + this.permissions = this.authStorageService.getPermissions(); + this.currentStepSub = this.stepsService.getCurrentStep().subscribe((step: WizardStepModel) => { + this.currentStep = step; + }); + this.currentStep.stepIndex = 1; } createCluster() { - this.notificationService.show( - NotificationType.error, - $localize`Cluster creation feature not implemented` - ); + this.startClusterCreation = true; } skipClusterCreation() { - this.clusterService.updateStatus('POST_INSTALLED').subscribe(() => { - this.notificationService.show( - NotificationType.info, - $localize`Cluster creation skipped by user` - ); - }); + const modalVariables = { + titleText: $localize`Warning`, + buttonText: $localize`Continue`, + warning: true, + bodyTpl: this.skipConfirmTpl, + showSubmit: true, + onSubmit: () => { + this.clusterService.updateStatus('POST_INSTALLED').subscribe({ + error: () => this.modalRef.close(), + complete: () => { + this.notificationService.show( + NotificationType.info, + $localize`Cluster expansion skipped by user` + ); + this.router.navigate(['/dashboard']); + this.modalRef.close(); + } + }); + } + }; + this.modalRef = this.modalService.show(ConfirmationModalComponent, modalVariables); + } + + onSubmit() { + forkJoin(this.observables) + .pipe( + finalize(() => + this.clusterService.updateStatus('POST_INSTALLED').subscribe(() => { + this.notificationService.show( + NotificationType.success, + $localize`Cluster expansion was successful` + ); + this.router.navigate(['/dashboard']); + }) + ) + ) + .subscribe({ + error: (error) => error.preventDefault() + }); + } + + onNextStep() { + if (!this.stepsService.isLastStep()) { + this.hostService.list().subscribe((hosts) => { + hosts.forEach((host) => { + if (host['status'] === 'maintenance') { + this.observables.push(this.hostService.update(host['hostname'], false, [], true)); + } + }); + }); + this.stepsService.moveToNextStep(); + } else { + this.onSubmit(); + } + } + + onPreviousStep() { + if (!this.stepsService.isFirstStep()) { + this.stepsService.moveToPreviousStep(); + } else { + this.router.navigate(['/dashboard']); + } + } + + showSubmitButtonLabel() { + return !this.stepsService.isLastStep() ? this.actionLabels.NEXT : $localize`Expand Cluster`; + } + + showCancelButtonLabel() { + return !this.stepsService.isFirstStep() ? this.actionLabels.BACK : this.actionLabels.CANCEL; + } + + ngOnDestroy(): void { + this.currentStepSub.unsubscribe(); } } diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/hosts/host-form/host-form.component.html b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/hosts/host-form/host-form.component.html index 487caf85b12de..a3477b9bd4102 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/hosts/host-form/host-form.component.html +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/hosts/host-form/host-form.component.html @@ -1,91 +1,95 @@ -
-
-
-
{{ action | titlecase }} {{ resource | upperFirst }}
+ + {{ action | titlecase }} {{ resource | upperFirst }} -
+ - -
- -
- - This field is required. - The chosen hostname is already in use. +
+ + + - -
- -
- - The value is not a valid IP address. + +
+ +
+ + The value is not a valid IP address. +
-
- -
- -
- - + +
+ +
+ + +
-
- -
-
-
- - + +
+
+
+ + +
-
- + +
- -
+ + diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/hosts/host-form/host-form.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/hosts/host-form/host-form.component.spec.ts index dbb834ea8c82c..ed3daf1e4b49b 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/hosts/host-form/host-form.component.spec.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/hosts/host-form/host-form.component.spec.ts @@ -3,6 +3,7 @@ import { ComponentFixture, fakeAsync, TestBed, tick } from '@angular/core/testin import { ReactiveFormsModule } from '@angular/forms'; import { RouterTestingModule } from '@angular/router/testing'; +import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'; import { ToastrModule } from 'ngx-toastr'; import { LoadingPanelComponent } from '~/app/shared/components/loading-panel/loading-panel.component'; @@ -24,7 +25,8 @@ describe('HostFormComponent', () => { ReactiveFormsModule, ToastrModule.forRoot() ], - declarations: [HostFormComponent] + declarations: [HostFormComponent], + providers: [NgbActiveModal] }, [LoadingPanelComponent] ); @@ -32,6 +34,7 @@ describe('HostFormComponent', () => { beforeEach(() => { fixture = TestBed.createComponent(HostFormComponent); component = fixture.componentInstance; + component.ngOnInit(); formHelper = new FormHelper(component.hostForm); fixture.detectChanges(); }); @@ -40,6 +43,11 @@ describe('HostFormComponent', () => { expect(component).toBeTruthy(); }); + it('should open the form in a modal', () => { + const nativeEl = fixture.debugElement.nativeElement; + expect(nativeEl.querySelector('cd-modal')).not.toBe(null); + }); + it('should validate the network address is valid', fakeAsync(() => { formHelper.setValue('addr', '115.42.150.37', true); tick(); diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/hosts/host-form/host-form.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/hosts/host-form/host-form.component.ts index b90312ff855f2..2fc8b13b540e6 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/hosts/host-form/host-form.component.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/hosts/host-form/host-form.component.ts @@ -2,6 +2,8 @@ import { Component, OnInit } from '@angular/core'; import { FormControl, Validators } from '@angular/forms'; import { Router } from '@angular/router'; +import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'; + import { HostService } from '~/app/shared/api/host.service'; import { SelectMessages } from '~/app/shared/components/select/select-messages.model'; import { ActionLabelsI18n, URLVerbs } from '~/app/shared/constants/app.constants'; @@ -24,6 +26,7 @@ export class HostFormComponent extends CdForm implements OnInit { addr: string; status: string; allLabels: any; + pageURL: string; messages = new SelectMessages({ empty: $localize`There are no labels.`, @@ -35,15 +38,19 @@ export class HostFormComponent extends CdForm implements OnInit { private router: Router, private actionLabels: ActionLabelsI18n, private hostService: HostService, - private taskWrapper: TaskWrapperService + private taskWrapper: TaskWrapperService, + public activeModal: NgbActiveModal ) { super(); this.resource = $localize`host`; - this.action = this.actionLabels.CREATE; - this.createForm(); + this.action = this.actionLabels.ADD; } ngOnInit() { + if (this.router.url.includes('hosts')) { + this.pageURL = 'hosts'; + } + this.createForm(); this.hostService.list().subscribe((resp: any[]) => { this.hostnames = resp.map((host) => { return host['hostname']; @@ -53,6 +60,7 @@ export class HostFormComponent extends CdForm implements OnInit { } private createForm() { + const disableMaintenance = this.pageURL !== 'hosts'; this.hostForm = new CdFormGroup({ hostname: new FormControl('', { validators: [ @@ -66,7 +74,7 @@ export class HostFormComponent extends CdForm implements OnInit { validators: [CdValidators.ip()] }), labels: new FormControl([]), - maintenance: new FormControl(false) + maintenance: new FormControl({ value: disableMaintenance, disabled: disableMaintenance }) }); } @@ -77,7 +85,7 @@ export class HostFormComponent extends CdForm implements OnInit { this.allLabels = this.hostForm.get('labels').value; this.taskWrapper .wrapTaskAroundCall({ - task: new FinishedTask('host/' + URLVerbs.CREATE, { + task: new FinishedTask('host/' + URLVerbs.ADD, { hostname: hostname }), call: this.hostService.create(hostname, this.addr, this.allLabels, this.status) @@ -87,7 +95,9 @@ export class HostFormComponent extends CdForm implements OnInit { this.hostForm.setErrors({ cdSubmitButton: true }); }, complete: () => { - this.router.navigate(['/hosts']); + this.pageURL === 'hosts' + ? this.router.navigate([this.pageURL, { outlets: { modal: null } }]) + : this.activeModal.close(); } }); } diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/hosts/hosts.component.html b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/hosts/hosts.component.html index f31adf9e5c0e7..59bab46d72d23 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/hosts/hosts.component.html +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/hosts/hosts.component.html @@ -5,12 +5,13 @@ Hosts List -
@@ -29,7 +30,7 @@
  • + *ngIf="permissions.grafana.read && !clusterCreation"> Overall Performance @@ -66,3 +67,4 @@ Are you sure you want to continue? + diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/hosts/hosts.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/hosts/hosts.component.spec.ts index f4d400f3230c8..049aceba0994a 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/hosts/hosts.component.spec.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/hosts/hosts.component.spec.ts @@ -72,6 +72,7 @@ describe('HostsComponent', () => { showForceMaintenanceModal = new MockShowForceMaintenanceModal(); fixture = TestBed.createComponent(HostsComponent); component = fixture.componentInstance; + component.clusterCreation = false; hostListSpy = spyOn(TestBed.inject(HostService), 'list'); orchService = TestBed.inject(OrchestratorService); }); @@ -182,7 +183,7 @@ describe('HostsComponent', () => { const tests = [ { expectResults: { - Create: { disabled: false, disableDesc: '' }, + Add: { disabled: false, disableDesc: '' }, Edit: { disabled: true, disableDesc: '' }, Delete: { disabled: true, disableDesc: '' } } @@ -190,7 +191,7 @@ describe('HostsComponent', () => { { selectRow: fakeHosts[0], // non-orchestrator host expectResults: { - Create: { disabled: false, disableDesc: '' }, + Add: { disabled: false, disableDesc: '' }, Edit: { disabled: true, disableDesc: component.messages.nonOrchHost }, Delete: { disabled: true, disableDesc: component.messages.nonOrchHost } } @@ -198,7 +199,7 @@ describe('HostsComponent', () => { { selectRow: fakeHosts[1], // orchestrator host expectResults: { - Create: { disabled: false, disableDesc: '' }, + Add: { disabled: false, disableDesc: '' }, Edit: { disabled: false, disableDesc: '' }, Delete: { disabled: false, disableDesc: '' } } @@ -222,7 +223,7 @@ describe('HostsComponent', () => { const tests = [ { expectResults: { - Create: resultNoOrchestrator, + Add: resultNoOrchestrator, Edit: { disabled: true, disableDesc: '' }, Delete: { disabled: true, disableDesc: '' } } @@ -230,7 +231,7 @@ describe('HostsComponent', () => { { selectRow: fakeHosts[0], // non-orchestrator host expectResults: { - Create: resultNoOrchestrator, + Add: resultNoOrchestrator, Edit: { disabled: true, disableDesc: component.messages.nonOrchHost }, Delete: { disabled: true, disableDesc: component.messages.nonOrchHost } } @@ -238,7 +239,7 @@ describe('HostsComponent', () => { { selectRow: fakeHosts[1], // orchestrator host expectResults: { - Create: resultNoOrchestrator, + Add: resultNoOrchestrator, Edit: resultNoOrchestrator, Delete: resultNoOrchestrator } @@ -255,7 +256,7 @@ describe('HostsComponent', () => { const tests = [ { expectResults: { - Create: resultMissingFeatures, + Add: resultMissingFeatures, Edit: { disabled: true, disableDesc: '' }, Delete: { disabled: true, disableDesc: '' } } @@ -263,7 +264,7 @@ describe('HostsComponent', () => { { selectRow: fakeHosts[0], // non-orchestrator host expectResults: { - Create: resultMissingFeatures, + Add: resultMissingFeatures, Edit: { disabled: true, disableDesc: component.messages.nonOrchHost }, Delete: { disabled: true, disableDesc: component.messages.nonOrchHost } } @@ -271,7 +272,7 @@ describe('HostsComponent', () => { { selectRow: fakeHosts[1], // orchestrator host expectResults: { - Create: resultMissingFeatures, + Add: resultMissingFeatures, Edit: resultMissingFeatures, Delete: resultMissingFeatures } diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/hosts/hosts.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/hosts/hosts.component.ts index 8c559a748b05d..1a3798b8ff9c0 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/hosts/hosts.component.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/hosts/hosts.component.ts @@ -1,4 +1,4 @@ -import { Component, OnInit, TemplateRef, ViewChild } from '@angular/core'; +import { Component, Input, OnInit, TemplateRef, ViewChild } from '@angular/core'; import { Router } from '@angular/router'; import { NgbModalRef } from '@ng-bootstrap/ng-bootstrap'; @@ -11,7 +11,7 @@ import { ConfirmationModalComponent } from '~/app/shared/components/confirmation import { CriticalConfirmationModalComponent } from '~/app/shared/components/critical-confirmation-modal/critical-confirmation-modal.component'; import { FormModalComponent } from '~/app/shared/components/form-modal/form-modal.component'; import { SelectMessages } from '~/app/shared/components/select/select-messages.model'; -import { ActionLabelsI18n } from '~/app/shared/constants/app.constants'; +import { ActionLabelsI18n, URLVerbs } from '~/app/shared/constants/app.constants'; import { TableComponent } from '~/app/shared/datatable/table/table.component'; import { CellTemplate } from '~/app/shared/enum/cell-template.enum'; import { Icons } from '~/app/shared/enum/icons.enum'; @@ -30,6 +30,7 @@ import { ModalService } from '~/app/shared/services/modal.service'; import { NotificationService } from '~/app/shared/services/notification.service'; import { TaskWrapperService } from '~/app/shared/services/task-wrapper.service'; import { URLBuilderService } from '~/app/shared/services/url-builder.service'; +import { HostFormComponent } from './host-form/host-form.component'; const BASE_URL = 'hosts'; @@ -46,6 +47,8 @@ export class HostsComponent extends ListWithDetails implements OnInit { public servicesTpl: TemplateRef; @ViewChild('maintenanceConfirmTpl', { static: true }) maintenanceConfirmTpl: TemplateRef; + @Input() + clusterCreation = false; permissions: Permissions; columns: Array = []; @@ -58,6 +61,8 @@ export class HostsComponent extends ListWithDetails implements OnInit { isExecuting = false; errorMessage: string; enableButton: boolean; + pageURL: string; + bsModalRef: NgbModalRef; icons = Icons; @@ -67,7 +72,7 @@ export class HostsComponent extends ListWithDetails implements OnInit { orchStatus: OrchestratorStatus; actionOrchFeatures = { - create: [OrchestratorFeature.HOST_CREATE], + add: [OrchestratorFeature.HOST_CREATE], edit: [OrchestratorFeature.HOST_LABEL_ADD, OrchestratorFeature.HOST_LABEL_REMOVE], delete: [OrchestratorFeature.HOST_DELETE], maintenance: [ @@ -80,7 +85,6 @@ export class HostsComponent extends ListWithDetails implements OnInit { private authStorageService: AuthStorageService, private hostService: HostService, private cephShortVersionPipe: CephShortVersionPipe, - private urlBuilder: URLBuilderService, private actionLabels: ActionLabelsI18n, private modalService: ModalService, private taskWrapper: TaskWrapperService, @@ -91,13 +95,6 @@ export class HostsComponent extends ListWithDetails implements OnInit { super(); this.permissions = this.authStorageService.getPermissions(); this.tableActions = [ - { - name: this.actionLabels.CREATE, - permission: 'create', - icon: Icons.add, - click: () => this.router.navigate([this.urlBuilder.getCreate()]), - disable: (selection: CdTableSelection) => this.getDisable('create', selection) - }, { name: this.actionLabels.EDIT, permission: 'update', @@ -118,7 +115,10 @@ export class HostsComponent extends ListWithDetails implements OnInit { icon: Icons.enter, click: () => this.hostMaintenance(), disable: (selection: CdTableSelection) => - this.getDisable('maintenance', selection) || this.isExecuting || this.enableButton + this.getDisable('maintenance', selection) || + this.isExecuting || + this.enableButton || + this.clusterCreation }, { name: this.actionLabels.EXIT_MAINTENANCE, @@ -126,12 +126,25 @@ export class HostsComponent extends ListWithDetails implements OnInit { icon: Icons.exit, click: () => this.hostMaintenance(), disable: (selection: CdTableSelection) => - this.getDisable('maintenance', selection) || this.isExecuting || !this.enableButton + this.getDisable('maintenance', selection) || + this.isExecuting || + !this.enableButton || + this.clusterCreation } ]; } ngOnInit() { + this.tableActions.unshift({ + name: this.actionLabels.ADD, + permission: 'create', + icon: Icons.add, + click: () => + this.clusterCreation + ? (this.bsModalRef = this.modalService.show(HostFormComponent)) + : this.router.navigate([BASE_URL, { outlets: { modal: [URLVerbs.ADD] } }]), + disable: (selection: CdTableSelection) => this.getDisable('add', selection) + }); this.columns = [ { name: $localize`Hostname`, @@ -141,6 +154,7 @@ export class HostsComponent extends ListWithDetails implements OnInit { { name: $localize`Services`, prop: 'services', + isHidden: this.clusterCreation, flexGrow: 3, cellTemplate: this.servicesTpl }, @@ -167,6 +181,7 @@ export class HostsComponent extends ListWithDetails implements OnInit { { name: $localize`Version`, prop: 'ceph_version', + isHidden: this.clusterCreation, flexGrow: 1, pipe: this.cephShortVersionPipe } @@ -287,7 +302,7 @@ export class HostsComponent extends ListWithDetails implements OnInit { } getDisable( - action: 'create' | 'edit' | 'delete' | 'maintenance', + action: 'add' | 'edit' | 'delete' | 'maintenance', selection: CdTableSelection ): boolean | string { if (action === 'delete' || action === 'edit' || action === 'maintenance') { diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/osd/osd-flags-indiv-modal/osd-flags-indiv-modal.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/osd/osd-flags-indiv-modal/osd-flags-indiv-modal.component.spec.ts index e85223c80e39c..93c9e9adcbbf0 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/osd/osd-flags-indiv-modal/osd-flags-indiv-modal.component.spec.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/osd/osd-flags-indiv-modal/osd-flags-indiv-modal.component.spec.ts @@ -1,6 +1,7 @@ import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing'; import { ComponentFixture, TestBed } from '@angular/core/testing'; import { ReactiveFormsModule } from '@angular/forms'; +import { RouterTestingModule } from '@angular/router/testing'; import { NgbActiveModal, NgbTooltipModule } from '@ng-bootstrap/ng-bootstrap'; import { ToastrModule } from 'ngx-toastr'; @@ -26,7 +27,8 @@ describe('OsdFlagsIndivModalComponent', () => { ReactiveFormsModule, SharedModule, ToastrModule.forRoot(), - NgbTooltipModule + NgbTooltipModule, + RouterTestingModule ], declarations: [OsdFlagsIndivModalComponent], providers: [NgbActiveModal] diff --git a/src/pybind/mgr/dashboard/frontend/src/app/core/auth/login/login.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/core/auth/login/login.component.spec.ts index 3cbfab4ebaac3..fc02e9bdeeefb 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/core/auth/login/login.component.spec.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/core/auth/login/login.component.spec.ts @@ -53,6 +53,6 @@ describe('LoginComponent', () => { component.login(); expect(routerNavigateSpy).toHaveBeenCalledTimes(1); - expect(routerNavigateSpy).toHaveBeenCalledWith(['/create-cluster']); + expect(routerNavigateSpy).toHaveBeenCalledWith(['/expand-cluster']); }); }); diff --git a/src/pybind/mgr/dashboard/frontend/src/app/core/auth/login/login.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/core/auth/login/login.component.ts index 77bafd99c82e0..a98548f94c766 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/core/auth/login/login.component.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/core/auth/login/login.component.ts @@ -65,10 +65,10 @@ export class LoginComponent implements OnInit { login() { this.authService.login(this.model).subscribe(() => { - const urlPath = this.postInstalled ? '/' : '/create-cluster'; + const urlPath = this.postInstalled ? '/' : '/expand-cluster'; let url = _.get(this.route.snapshot.queryParams, 'returnUrl', urlPath); if (!this.postInstalled && this.route.snapshot.queryParams['returnUrl'] === '/dashboard') { - url = '/create-cluster'; + url = '/expand-cluster'; } this.router.navigate([url]); }); diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/components.module.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/components.module.ts index ef8b423a3a70c..a6d0624d31885 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/components.module.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/components.module.ts @@ -45,6 +45,7 @@ import { SparklineComponent } from './sparkline/sparkline.component'; import { SubmitButtonComponent } from './submit-button/submit-button.component'; import { TelemetryNotificationComponent } from './telemetry-notification/telemetry-notification.component'; import { UsageBarComponent } from './usage-bar/usage-bar.component'; +import { WizardComponent } from './wizard/wizard.component'; @NgModule({ imports: [ @@ -93,7 +94,8 @@ import { UsageBarComponent } from './usage-bar/usage-bar.component'; Copy2ClipboardButtonComponent, DownloadButtonComponent, FormButtonPanelComponent, - MotdComponent + MotdComponent, + WizardComponent ], providers: [], exports: [ @@ -120,7 +122,8 @@ import { UsageBarComponent } from './usage-bar/usage-bar.component'; Copy2ClipboardButtonComponent, DownloadButtonComponent, FormButtonPanelComponent, - MotdComponent + MotdComponent, + WizardComponent ] }) export class ComponentsModule {} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/modal/modal.component.html b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/modal/modal.component.html index 5cbd4f58c52fa..657e0d6053f89 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/modal/modal.component.html +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/modal/modal.component.html @@ -1,13 +1,19 @@ - +
    +
    + +
    +
    diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/modal/modal.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/modal/modal.component.spec.ts index d3ee1ca2abd19..cf08bef10090d 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/modal/modal.component.spec.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/modal/modal.component.spec.ts @@ -1,4 +1,6 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { Router } from '@angular/router'; +import { RouterTestingModule } from '@angular/router/testing'; import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'; @@ -8,14 +10,18 @@ import { ModalComponent } from './modal.component'; describe('ModalComponent', () => { let component: ModalComponent; let fixture: ComponentFixture; + let routerNavigateSpy: jasmine.Spy; configureTestBed({ - declarations: [ModalComponent] + declarations: [ModalComponent], + imports: [RouterTestingModule] }); beforeEach(() => { fixture = TestBed.createComponent(ModalComponent); component = fixture.componentInstance; + routerNavigateSpy = spyOn(TestBed.inject(Router), 'navigate'); + routerNavigateSpy.and.returnValue(true); fixture.detectChanges(); }); @@ -38,4 +44,11 @@ describe('ModalComponent', () => { component.close(); expect(component.modalRef.close).toHaveBeenCalled(); }); + + it('should hide the routed modal', () => { + component.pageURL = 'hosts'; + component.close(); + expect(routerNavigateSpy).toHaveBeenCalledTimes(1); + expect(routerNavigateSpy).toHaveBeenCalledWith(['hosts', { outlets: { modal: null } }]); + }); }); diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/modal/modal.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/modal/modal.component.ts index 730da6d62527b..25e06e62af188 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/modal/modal.component.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/modal/modal.component.ts @@ -1,4 +1,5 @@ import { Component, EventEmitter, Input, Output } from '@angular/core'; +import { Router } from '@angular/router'; import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'; @@ -10,6 +11,8 @@ import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'; export class ModalComponent { @Input() modalRef: NgbActiveModal; + @Input() + pageURL: string; /** * Should be a function that is triggered when the modal is hidden. @@ -17,8 +20,12 @@ export class ModalComponent { @Output() hide = new EventEmitter(); + constructor(private router: Router) {} + close() { - this.modalRef?.close(); + this.pageURL + ? this.router.navigate([this.pageURL, { outlets: { modal: null } }]) + : this.modalRef?.close(); this.hide.emit(); } } diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/wizard/wizard.component.html b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/wizard/wizard.component.html new file mode 100644 index 0000000000000..25aa3e1df855e --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/wizard/wizard.component.html @@ -0,0 +1,19 @@ + diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/wizard/wizard.component.scss b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/wizard/wizard.component.scss new file mode 100644 index 0000000000000..80e3550cd68a5 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/wizard/wizard.component.scss @@ -0,0 +1,30 @@ +@use './src/styles/vendor/variables' as vv; + +.card-body { + padding-left: 0; +} + +span.circle-step { + background: vv.$gray-500; + border-radius: 0.8em; + color: vv.$white; + display: inline-block; + font-weight: bold; + line-height: 1.6em; + margin-right: 5px; + text-align: center; + width: 1.6em; + + &.active { + background-color: vv.$primary; + } +} + +.nav-pills .nav-link { + background-color: vv.$white; + color: vv.$gray-800; + + &.active { + color: vv.$primary; + } +} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/wizard/wizard.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/wizard/wizard.component.spec.ts new file mode 100644 index 0000000000000..b42578fb71193 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/wizard/wizard.component.spec.ts @@ -0,0 +1,25 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { SharedModule } from '~/app/shared/shared.module'; +import { configureTestBed } from '~/testing/unit-test-helper'; +import { WizardComponent } from './wizard.component'; + +describe('WizardComponent', () => { + let component: WizardComponent; + let fixture: ComponentFixture; + + configureTestBed({ + imports: [SharedModule] + }); + + beforeEach(() => { + fixture = TestBed.createComponent(WizardComponent); + component = fixture.componentInstance; + component.stepsTitle = ['Add Hosts', 'Review']; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/wizard/wizard.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/wizard/wizard.component.ts new file mode 100644 index 0000000000000..d46aa480e7918 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/wizard/wizard.component.ts @@ -0,0 +1,39 @@ +import { Component, Input, OnDestroy, OnInit } from '@angular/core'; + +import * as _ from 'lodash'; +import { Observable, Subscription } from 'rxjs'; + +import { WizardStepModel } from '~/app/shared/models/wizard-steps'; +import { WizardStepsService } from '~/app/shared/services/wizard-steps.service'; + +@Component({ + selector: 'cd-wizard', + templateUrl: './wizard.component.html', + styleUrls: ['./wizard.component.scss'] +}) +export class WizardComponent implements OnInit, OnDestroy { + @Input() + stepsTitle: string[]; + + steps: Observable; + currentStep: WizardStepModel; + currentStepSub: Subscription; + + constructor(private stepsService: WizardStepsService) {} + + ngOnInit(): void { + this.stepsService.setTotalSteps(this.stepsTitle.length); + this.steps = this.stepsService.getSteps(); + this.currentStepSub = this.stepsService.getCurrentStep().subscribe((step: WizardStepModel) => { + this.currentStep = step; + }); + } + + onStepClick(step: WizardStepModel) { + this.stepsService.setCurrentStep(step); + } + + ngOnDestroy(): void { + this.currentStepSub.unsubscribe(); + } +} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/models/wizard-steps.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/models/wizard-steps.ts new file mode 100644 index 0000000000000..177feb486d1a1 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/models/wizard-steps.ts @@ -0,0 +1,4 @@ +export interface WizardStepModel { + stepIndex: number; + isComplete: boolean; +} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/services/task-message.service.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/services/task-message.service.ts index c39bb0c26b695..44eb9bd30978a 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/shared/services/task-message.service.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/services/task-message.service.ts @@ -114,9 +114,7 @@ export class TaskMessageService { messages = { // Host tasks - 'host/create': this.newTaskMessage(this.commonOperations.create, (metadata) => - this.host(metadata) - ), + 'host/add': this.newTaskMessage(this.commonOperations.add, (metadata) => this.host(metadata)), 'host/delete': this.newTaskMessage(this.commonOperations.delete, (metadata) => this.host(metadata) ), diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/services/wizard-steps.service.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/services/wizard-steps.service.spec.ts new file mode 100644 index 0000000000000..47c2149756703 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/services/wizard-steps.service.spec.ts @@ -0,0 +1,16 @@ +import { TestBed } from '@angular/core/testing'; + +import { WizardStepsService } from './wizard-steps.service'; + +describe('WizardStepsService', () => { + let service: WizardStepsService; + + beforeEach(() => { + TestBed.configureTestingModule({}); + service = TestBed.inject(WizardStepsService); + }); + + it('should be created', () => { + expect(service).toBeTruthy(); + }); +}); diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/services/wizard-steps.service.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/services/wizard-steps.service.ts new file mode 100644 index 0000000000000..e0fb2be944de0 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/services/wizard-steps.service.ts @@ -0,0 +1,58 @@ +import { Injectable } from '@angular/core'; + +import { BehaviorSubject, Observable } from 'rxjs'; + +import { WizardStepModel } from '~/app/shared/models/wizard-steps'; + +const initialStep = [{ stepIndex: 1, isComplete: false }]; + +@Injectable({ + providedIn: 'root' +}) +export class WizardStepsService { + steps$: BehaviorSubject; + currentStep$: BehaviorSubject = new BehaviorSubject(null); + + constructor() { + this.steps$ = new BehaviorSubject(initialStep); + this.currentStep$.next(this.steps$.value[0]); + } + + setTotalSteps(step: number) { + const steps: WizardStepModel[] = []; + for (let i = 1; i <= step; i++) { + steps.push({ stepIndex: i, isComplete: false }); + } + this.steps$ = new BehaviorSubject(steps); + } + + setCurrentStep(step: WizardStepModel): void { + this.currentStep$.next(step); + } + + getCurrentStep(): Observable { + return this.currentStep$.asObservable(); + } + + getSteps(): Observable { + return this.steps$.asObservable(); + } + + moveToNextStep(): void { + const index = this.currentStep$.value.stepIndex; + this.currentStep$.next(this.steps$.value[index]); + } + + moveToPreviousStep(): void { + const index = this.currentStep$.value.stepIndex - 1; + this.currentStep$.next(this.steps$.value[index - 1]); + } + + isLastStep(): boolean { + return this.currentStep$.value.stepIndex === this.steps$.value.length; + } + + isFirstStep(): boolean { + return this.currentStep$.value?.stepIndex - 1 === 0; + } +} diff --git a/src/pybind/mgr/dashboard/frontend/src/styles/ceph-custom/_forms.scss b/src/pybind/mgr/dashboard/frontend/src/styles/ceph-custom/_forms.scss index cca9bd5d5d9d8..3c6ddbf80c998 100644 --- a/src/pybind/mgr/dashboard/frontend/src/styles/ceph-custom/_forms.scss +++ b/src/pybind/mgr/dashboard/frontend/src/styles/ceph-custom/_forms.scss @@ -53,6 +53,17 @@ } cd-modal { + .modal { + /* stylelint-disable */ + background-color: rgba(0, 0, 0, 0.4); + /* stylelint-enable */ + display: block; + } + + .modal-dialog { + max-width: 70vh; + } + .cd-col-form-label { @extend .col-lg-4; }